Responsive D3.js bar chart with labels

Today I learned some cool stuff with D3.js!

Here is a minimalist responsive bar chart with quantity labels at the top of each bar and text wrapping of the food labels. It is actually responsive, it doesn’t merely scale the SVG proportionally, it keeps a fixed height and dynamically changes the width.

For simplicity I took the left scale off. All bars are proportional and are labeled anyway.

Go ahead and resize your window! This has a minimum width of about 530px because of the text labels. Any smaller than that and they are very difficult to read.

The basic HTML




The Styles

You’ll see that the axis is actually there but it is white. I found it useful to learn to draw it, but I didn’t want it so I am keeping it hidden.

.axis path, .axis line {
fill: none;
stroke: #fff;
}
.axis text {
font-size: 13px;
}
.bar {
fill: #8CD3DD;
}
.bar:hover {
fill: #F56C4E;
}
svg text.label {
fill:white;
font: 15px;
font-weight: 400;
text-anchor: middle;
}
#chartID {
min-width: 531px;
}

The Data

var data = [{"food":"Hotdogs","quantity":24},{"food":"Tacos","quantity":15},{"food":"Pizza","quantity":3},{"food":"Double Quarter Pounders with Cheese","quantity":2},{"food":"Omelets","quantity":30},{"food":"Falafel and Hummus","quantity":21},{"food":"Soylent","quantity":13}]

The Javascript Heavy Lifting

This is where D3 really comes in.

Setting the margins, sizes, and figuring out the basic scale.

Setting the axes

Drawing the basic SVG container with the proper size and margins

Scaling the axes

Drawing the bars themselves

var margin = {top:10, right:10, bottom:90, left:10};

var width = 960 - margin.left - margin.right;

var height = 500 - margin.top - margin.bottom;

var xScale = d3.scale.ordinal().rangeRoundBands([0, width], .03)

var yScale = d3.scale.linear()
.range([height, 0]);

var xAxis = d3.svg.axis()
.scale(xScale)
.orient("bottom");

var yAxis = d3.svg.axis()
.scale(yScale)
.orient("left");

var svgContainer = d3.select("#chartID").append("svg")
.attr("width", width+margin.left + margin.right)
.attr("height",height+margin.top + margin.bottom)
.append("g").attr("class", "container")
.attr("transform", "translate("+ margin.left +","+ margin.top +")");

xScale.domain(data.map(function(d) { return d.food; }));
yScale.domain([0, d3.max(data, function(d) { return d.quantity; })]);

//xAxis. To put on the top, swap "(height)" with "-5" in the translate() statement. Then you'll have to change the margins above and the x,y attributes in the svgContainer.select('.x.axis') statement inside resize() below.
var xAxis_g = svgContainer.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + (height) + ")")
.call(xAxis)
.selectAll("text");

// Uncomment this block if you want the y axis
/*var yAxis_g = svgContainer.append("g")
.attr("class", "y axis")
.call(yAxis)
.append("text")
.attr("transform", "rotate(-90)")
.attr("y", 6).attr("dy", ".71em")
//.style("text-anchor", "end").text("Number of Applicatons");
*/

svgContainer.selectAll(".bar")
.data(data)
.enter()
.append("rect")
.attr("class", "bar")
.attr("x", function(d) { return xScale(d.food); })
.attr("width", xScale.rangeBand())
.attr("y", function(d) { return yScale(d.quantity); })
.attr("height", function(d) { return height - yScale(d.quantity); });

Adding the quantity labels to the top of each bar

This took me a while to figure out because I was originally appending to the rect element. According to the SVG specs this is illegal, so I moved on to appending them after everything else to they’d show on top. The positioning is tricky, too. I eventually found the correct variables to position it close to center. Then text-anchor: middle; sealed the deal.

// Controls the text labels at the top of each bar. Partially repeated in the resize() function below for responsiveness.
svgContainer.selectAll(".text")
.data(data)
.enter()
.append("text")
.attr("class","label")
.attr("x", (function(d) { return xScale(d.food) + xScale.rangeBand() / 2 ; } ))
.attr("y", function(d) { return yScale(d.quantity) + 1; })
.attr("dy", ".75em")
.text(function(d) { return d.quantity; });

Responsiveness

The general method for making D3 charts responsive is to scale the SVG down proportionally as the window gets smaller by manipulating the viewBox and preserveAspectRatio attributes. But after digging around on Github for a while, I found a fancier solution that preserves the height and redraws the SVG as the width shrinks.

document.addEventListener("DOMContentLoaded", resize);
d3.select(window).on('resize', resize);

function resize() {
console.log('----resize function----');
// update width
width = parseInt(d3.select('#chartID').style('width'), 10);
width = width - margin.left - margin.right;

height = parseInt(d3.select("#chartID").style("height"));
height = height - margin.top - margin.bottom;
console.log('----resiz width----'+width);
console.log('----resiz height----'+height);
// resize the chart

xScale.range([0, width]);
xScale.rangeRoundBands([0, width], .03);
yScale.range([height, 0]);

yAxis.ticks(Math.max(height/50, 2));
xAxis.ticks(Math.max(width/50, 2));

d3.select(svgContainer.node().parentNode)
.style('width', (width + margin.left + margin.right) + 'px');

svgContainer.selectAll('.bar')
.attr("x", function(d) { return xScale(d.food); })
.attr("width", xScale.rangeBand());

svgContainer.selectAll("text")
// .attr("x", function(d) { return xScale(d.food); })
.attr("x", (function(d) { return xScale(d.food ) + xScale.rangeBand() / 2 ; } ))
.attr("y", function(d) { return yScale(d.quantity) + 1; })
.attr("dy", ".75em");

svgContainer.select('.x.axis').call(xAxis.orient('bottom')).selectAll("text").attr("y",10).call(wrap, xScale.rangeBand());
// Swap the version below for the one above to disable rotating the titles
// svgContainer.select('.x.axis').call(xAxis.orient('top')).selectAll("text").attr("x",55).attr("y",-25);

}

Wrapping text labels

Wrapping text labels is tricky. The best solution I found is the one Mike Bostock (D3’s creator) describes. I modified it slightly to work with my chart, but the overall solution is the same.

function wrap(text, width) {
text.each(function() {
var text = d3.select(this),
words = text.text().split(/\s+/).reverse(),
word,
line = [],
lineNumber = 0,
lineHeight = 1.1, // ems
y = text.attr("y"),
dy = parseFloat(text.attr("dy")),
tspan = text.text(null).append("tspan").attr("x", 0).attr("y", y).attr("dy", dy + "em");
while (word = words.pop()) {
line.push(word);
tspan.text(line.join(" "));
if (tspan.node().getComputedTextLength() > width) {
line.pop();
tspan.text(line.join(" "));
line = [word];
tspan = text.append("tspan").attr("x", 0).attr("y", y).attr("dy", ++lineNumber * lineHeight + dy + "em").text(word);
}
}
});
}