David Banks

Web development tips and tutorials

Building interactive charts with D3

8 February 2015D3.jsJavaScriptSVG

This post explores how to build an interactive line chart using D3. D3 is an excellent javascript library for data visualisation and working with SVG in general. Concepts covered include transitions, zooming/panning and tooltips.

Using D3, we can create awesome charts without an enormous amount of work. This is exactly what we are going to do in this post! Here is the final chart we want to build:


These are the HTML elements we will need to draw our chart elements; a button to reset zoom (our chart will be zoomable!) and below that a div element which will hold the svg content for our chart:



<button type="button" id="reset-zoom">Reset zoom</button>
<div id="temp-chart"></div>


We will use some temperature data from my own weather station for this post. In particular, we will plot a graph of daily high temperatures for the whole year of 2014. Let's say we have a variable called data - an array of objects with a length of 365:



data = [
	{Date: '01/01/2014', HighTemperature: 23.3},
	{Date: '02/01/2014', HighTemperature: 25.6},
	{Date: '03/01/2014', HighTemperature: 26.9},
	// ... etc
];


Formatting the data

The above data format is typical of what we get when D3 reads in a CSV file. Using this data set we will create some separate data arrays to work with:



// This is the format our dates are in, e.g 23/05/2014
var timeFormat = d3.time.format('%d/%m/%Y');

var dates = [],
    dateStrings = [],
    temps = [];

data.forEach(function(d) {

	// Keep array of original date strings
	dateStrings.push(d.Date);

	// Convert date string into JS date objects, add to dates array
	dates.push(timeFormat.parse(d.Date));

	// Add high temperature to temps array
	temps.push(+d.HighTemperature);

});


Setting up the chart

Now specify the width, height and margins of our chart. The margins are necessary in order to make sure we have enough space for axes labels. Create the svg element and set its width and height:



var chartWidth = 750,
    chartHeight = 250;

var margin = {
	top: 5,
	right: 25,
	bottom: 20,
	left: 25
};

var container = d3.select('#temp-chart');

var svg = container.append('svg')
	.attr('width', chartWidth)
	.attr('height', chartHeight);


Next we'll create an SVG clip path. In this case, the clip path is simply a rectangular area. This clip path will be applied to the series plot area of our chart, and will make sure series elements don't overflow beyond the vertical axes when the chart is zoomed in.



var defs = svg.append('defs');

// clipping area
defs.append('clipPath')	
	.attr('id', 'plot-area-clip-path')
	.append('rect')
		.attr({
			x: margin.left,
			y: margin.top,
			width: chartWidth - margin.right - margin.left
			height: chartHeight - margin.top - margin.bottom
		});


Next we create the background rectangle. Here we make it invisible by setting fill-opacity to 0, but you can choose any colour and opacity you wish. We set the pointer-events attribute to all. After that, we add a g element which will hold the chart axes. And then we add a g element which will hold the chart plot-area elements, and apply the clip path we defined earlier to it. Note that we set pointer-events to none on these two g elements because we want the background rectangle to capture all the events. Without a background rectangle mouse events would not fire on the whole plot area, because there is nothing in the plot area apart from series path elements and gridlines. The way we have done it means mouse events are triggered over the whole plot area.



// Invisible background rect to capture all zoom events
var backRect = svg.append('rect')
	.style('stroke', 'none')
	.style('fill', '#FFF')
	.style('fill-opacity', 0)
	.attr({
		x: margin.left,
		y: margin.top,
		width: chartWidth - margin.right - margin.left,
		height: chartHeight - margin.top - margin.bottom,
		'pointer-events': 'all'
	});

// g element to hold chart axes
var axes = svg.append('g')
	.attr('pointer-events', 'none')
	.style('font-size', '11px');

// g element to hold chart plot-area content
var chart = svg.append('g')
	.attr('class', 'plot-area')
	.attr('pointer-events', 'none')
	.attr('clip-path', 'url(#plot-area-clip-path)');


Scales and axes

Now create the scales and axes and add them to the chart. In this example we are adding a y-axis on both sides of the chart plot area, and an x-axis below the chart plot area:



// x scale
var xScale = d3.time.scale()
	.range([margin.left, chartWidth - margin.right])
	.domain(d3.extent(dates));

// Calculate the range of the temperature data
var yExtent = d3.extent(temps);
var yRange = yExtent[1] - yExtent[0];

// Adjust the lower and upper bounds to force the data
// to fit into the y limits nicely
yExtent[0] = yExtent[0] - yRange * 0.1;
yExtent[1] = yExtent[1] + yRange * 0.1;

// the y scale
var yScale = d3.scale.linear()
	.range([chartHeight - margin.bottom, margin.top])
	.domain(yExtent);

// x axis
var xAxis = d3.svg.axis()
	.orient('bottom')
	.outerTickSize(0)
	.innerTickSize(0)
	.scale(xScale);

// y axis - left of chart
var yAxis = d3.svg.axis()
	.orient('left')
	.outerTickSize(0)
	.innerTickSize(- (chartWidth - margin.left - margin.right))  // horizontal gridlines
	.scale(yScale);

// y axis - right of chart
var yAxis2 = d3.svg.axis()
	.orient('right')
	.outerTickSize(0)
	.innerTickSize(0)
	.scale(yScale);

// Add the x axis to the chart
var xAxisEl = axes.append('g')
	.attr('class', 'x-axis')
	.attr('transform', 'translate(' + 0 + ',' + (chartHeight - margin.bottom) + ')')
	.call(xAxis);

// Add the left y axis to the chart
var yAxisEl = axes.append('g')
	.attr('class', 'y-axis')
	.attr('transform', 'translate(' + margin.left + ',' + 0 + ')')
	.call(yAxis);

// Add the right y axis to the chart
var yAxisEl2 = axes.append('g')
	.attr('class', 'y-axis right')
	.attr('transform', 'translate(' + (chartWidth - margin.right) + ',' + 0 + ')')
	.call(yAxis2);

// Format y-axis gridlines
yAxisEl.selectAll('line')
	.style('stroke', '#BBB')
	.style('stroke-width', '1px')
	.style('shape-rendering', 'crispEdges');


Adding the series

Now add the series elements to the chart. Note the vector-effect attribute being set to non-scaling-stroke on the path element. This has the effect of keeping the path element the same thickness when zoomed in or out. This is especially necessary for the chart we want to create because we only allow zooming along the x-axis. Without setting this attribute, when we zoom in our path element would get thicker in the x-direction only! Trust me, that looks odd and is certainly not what a user would expect to see.



// Start data as a flat line at the average
var avgTempY = yScale(d3.mean(temps));

// Path generator function for our data
var pathGenerator = d3.svg.line()
	.x(function(d, i) { return xScale(dates[i]); })
	.y(function(d, i) { return yScale(temps[i]); });

// Series container element
var series = chart.append('g');

// Add the temperature series path to the chart
series.append('path')
	.attr('vector-effect', 'non-scaling-stroke')
	.style('fill', 'none')
	.style('stroke', 'red')
	.style('stroke-width', '1px')
	.attr('d', pathGenerator(dates));


Zooming and panning

Next we create a D3 zooming behaviour. In this example, I have allowed a minimum scale of 1 (original size) and a maximum scale of 12. In order to be able to update the x-axis when a zoom or panning event occurs, we need to set the x option to our x scale object. When a zoom/pan event occurs, we have specified a function zoomHandler() that will be executed whenever the user zooms or pans the chart plot area.

This function first updates the x axis, and then it applies translate and scale transforms to the series container element. Note that we have fixed the second value in the translate(x,y) part of the transform at 0. This means panning can only occur along the date axis and not on the y-axis. Similarly, we set the second value in the scale(x,y) part of the transform to 1. This means the zooming can only occur along the date axis as well.



// Add zooming and panning functionality, only along the x axis
var zoom = d3.behavior.zoom()
	.scaleExtent([1, 12])
	.x(xScale)
	.on('zoom', function zoomHandler() {

		axes.select('.x-axis')
			.call(xAxis);

		series.attr('transform', 'translate(' + d3.event.translate[0] + ',0) '
			+ 'scale(' + d3.event.scale + ',1)');

	});

// The backRect captures zoom/pan events
backRect.call(zoom);


Next we define a function that should be called when the reset zoom button is clicked. This is just a convenience feature to return the chart to its original state. The resetZoom() function updates the scale and translate properties of our zoom behaviour object, resets the x scale domain to the full data range, and updates the axis accordingly. Finally, it uses a transition to smoothly shift the series elements back to their original position and scale.



// Function for resetting any scaling and translation applied
// during zooming and panning. Returns chart to original state.
function resetZoom() {

	zoom.scale(1);
	zoom.translate([0, 0]);
	
	// Set x scale domain to the full data range
	xScale.domain(d3.extent(dates));

	// Update the x axis elements to match
	axes.select('.x-axis')
		.transition()
		.call(xAxis);

	// Remove any transformations applied to series elements
	series.transition()
		.attr('transform', "translate(0,0) scale(1,1)");

};

// Call resetZoom function when the button is clicked
d3.select("#reset-zoom").on("click", resetZoom);


Adding tooltips

Finally, we want to add tooltips to our chart to make it more fancy. We are going to use an HTML tooltip, not an SVG tooltip. Start by adding a circle element to the chart, which I'll refer to as the 'active point'. Set its fill-opacity to 0 to make it initially hidden. Then we insert our tooltip div element in the chart container. To make the manual positioning of our tooltip easier, we set the chart container div element to have relative positioning, and the tooltip to have absolute positioning. Note that because the tooltip it is added after the SVG content, it will appear over the top of the chart by default, which is what we want! Following on from this, we define 3 tooltip-related functions: one for hiding the tooltip, one for showing the tooltip, and one for nicely formatting the tooltip content.



// Active point element
var activePoint = svg.append('circle')
	.attr({
		cx: 0,
		cy: 0,
		r: 5,
		'pointer-events': 'none'
	})
	.style({
		stroke: 'none',
		fill: 'red',
		'fill-opacity': 0
	});

// Set container to have relative positioning. This allows us to easily
// position the tooltip element with absolute positioning.
container.style('position', 'relative');

// Create the tooltip element. Hidden initially.
var tt = container.append('div')
	.style({padding: '5px',
		border: '1px solid #AAA',
		color: 'black',
		position: 'absolute',
		visibility: 'hidden',
		'background-color': '#F5F5F5'
	});


// Function for hiding the tooltip
function hideTooltip() {

	tt.style('visibility', 'hidden');
	activePoint.style('fill-opacity', 0);

}

// Function for showing the tooltip
function showTooltip() {

	tt.style('visibility', 'visible');
	activePoint.style('fill-opacity', 1);

}

// Tooltip content formatting function
function tooltipFormatter(date, temp) {

	var dateFormat = d3.time.format('%d %b %Y');
	return dateFormat(date) + '<br><b>' + temp.toFixed(1) + '°C' + '</b>';

}


Now we add mouse event handlers to the background rectangle. When the mousemove event fires, we want to highlight the closest point to our current x-coordinate of the mouse. This is where d3.mouse() comes in handy - it gives us the mouse coordinates of the current event relative to the specified containing element: in this case the chart container div element. This will give us coordinates we can use to set the left and top CSS properties of the tooltip element.

Once we have these coordinates, we then figure out what date on the x-axis that this location corresponds to. We check to see if this date is contained within our data. If it isn't, we hide the tooltip and return from the function. If the date was found in our data, we move the active point to sit at the location of the data point for this date. Then we call the tooltipFormatter() function with the date and temperature values for this point, before shifting the tooltip to its new location. Finally we ensure the tooltip is actually visible - no point in all that work if no one can see it! :)

Once our mousemove event handler is sorted, we add a mouseout event handler. When the mouse leaves the chart plot area, we simply call the hideTooltip() function to hide the tooltip and the active point.

Note that the tooltip follows the mouse cursor rather than the data series itself. You can adjust this to make it follow the data series if you prefer (warning - this can cause the tooltip to be quite jumpy though!).



backRect.on('mousemove', function() {

	// Coords of mousemove event relative to the container div
	var coords = d3.mouse(container.node());

	// Value on the x scale corresponding to this location
	var xVal = xScale.invert(coords[0]);

	// Date object corresponding the this x value. Add 12 hours to
	// the value, so that each point occurs at midday on the given date.
	// This means date changes occur exactly halfway between points.
	// This is what we want - we want our tooltip to display data for the
	// closest point to the mouse cursor.
	var d = new Date(xVal.getTime() + 3600000 * 12);

	// Format the date object as a date string matching our original data
	var date = timeFormat(d);

	// Find the index of this date in the array of original date strings
	var i = dateStrings.indexOf(date);

	// Does this date exist in the original data?
	var dateExists = i > -1;

	// If not, hide the tooltip and return from this function
	if (!dateExists) {
		hideTooltip();
		return;
	}

	// If we are here, the date was found in the original data.
	// Proceed with displaying tooltip for of the i-th data point.

	// Get the i-th date value and temperature value.
	var _date = dates[i],
	    _temp = temps[i];

	// Update the position of the activePoint element
	activePoint.attr({
		cx: xScale(_date),
		cy: yScale(_temp)
	});

	// Update tooltip content
	tt.html(tooltipFormatter(_date, _temp));

	// Get dimensions of tooltip element
	var dim = tt.node().getBoundingClientRect();

	// Update the position of the tooltip. By default, above and to the right
	// of the mouse cursor.
	var tt_top = coords[1] - dim.height - 10,
	    tt_left = coords[0] + 10;

	// If right edge of tooltip goes beyond chart container, force it to move
	// to the left of the mouse cursor.
	if (tt_left + dim.width > chartWidth)
		tt_left = coords[0] - dim.width - 10;

	tt.style({
		top: tt_top + 'px',
		left: tt_left + 'px'
	});
	
	// Show tooltip if it is not already visible
	if (tt.style('visibility') != 'visible')
		showTooltip();

});


// Add mouseout event handler
backRect.on('mouseout', hideTooltip);


We're done! We now have an interactive chart to show off. Hopefully you enjoyed this post and found it useful. If you've got any questions please post them below and I'll do my best to answer them.