D3: Clicking and Hovering Around

Here we will cover a (tiny) portion of the design space for interactions. Our working example will be the following scatterplot:

d3.json('iris.json')
  .then(function(data)  {
	  iris_data = data;
	  plot_example0();
})

function plot_example0() {
	var svg_elem = d3.select('#svg0');
	var pad = 50;
	var width = svg_elem.attr('width'), height = svg_elem.attr('height');
	var actual_width = height-2*pad, actual_height = height-2*pad;

	var sepal_width_extent = d3.extent(iris_data, d => d.sepalWidth);
	var sepal_length_extent = d3.extent(iris_data, d => d.sepalLength);
	var x_data_pad = 0.03*(sepal_width_extent[1]-sepal_width_extent[0]), y_data_pad = 0.03*(sepal_length_extent[1]-sepal_length_extent[0]);
	var x_scale = d3.scaleLinear()
		.domain([sepal_width_extent[0]-x_data_pad,sepal_width_extent[1]+x_data_pad])
		.range([0,actual_width]).nice();
	var y_scale = d3.scaleLinear()
		.domain([sepal_length_extent[0]-y_data_pad,sepal_length_extent[1]+y_data_pad])
		.range([actual_height,0]).nice();

	var unique_species = d3.set(iris_data, d => d.species).values();
	var hue_scale = d3.scalePoint().domain(unique_species).range([0,360]).padding(0.5);

	var plot_group = svg_elem.append('g').attr('transform', 'translate('+pad+','+pad+')')
	plot_group.selectAll('empty').data(iris_data).enter().append('circle')
		.attr('cx', d => x_scale(d.sepalWidth)).attr('cy', d => y_scale(d.sepalLength))
		.attr('fill', d => d3.hcl(hue_scale(d.species),40,65)).attr('stroke', 'black').attr('stroke-width', 0.5).attr('r', 5)

	plot_group.append('g').attr('transform', 'translate('+0+','+0+')').call(d3.axisLeft(y_scale));
	plot_group.append('g').attr('transform', 'translate('+0+','+(actual_height)+')').call(d3.axisBottom(x_scale));

	plot_group.append('text').text('Sepal Width')
		.attr('transform', 'translate('+(actual_width/2)+','+(actual_height+40)+')').attr('text-anchor', 'middle')
	plot_group.append('text').text('Sepal Length')
		.attr('transform', 'translate('+(-35)+','+(actual_height/2)+') rotate(270)').attr('text-anchor', 'middle')

	var legend_scale = d3.scaleBand().domain(unique_species).range([80,0]).paddingInner(0.1);
	var species_legend_group = svg_elem.append('g').attr('transform', 'translate('+(pad+actual_width+15)+','+pad+')')
	var species_enter = species_legend_group.selectAll('empty').data(unique_species).enter();
	species_enter.append('rect')
		.attr('y', d => legend_scale(d)).attr('width', legend_scale.bandwidth()).attr('height', legend_scale.bandwidth())
		.attr('fill', d => d3.hcl(hue_scale(d),40,65))
	species_enter.append('text')
		.attr('x', (4+legend_scale.bandwidth())).attr('y', d => legend_scale(d)+legend_scale.bandwidth()/2)
		.text(d => d).attr('alignment-baseline', 'middle')
}



We want our SVG elements to be responsive to user interactions. D3 supports events triggered by inputs from the user (e.g. mouse, keyboard) via on, which is a function of a selection object. The on function has two arguments:

Hence, the first argument indicates the type of interaction we are supporting, and with the second argument, we specify what to do for the interaction. Furthermore, what we do can be customized for each element in the selection, since D3 provides us the data bound to elements.

So, let’s now go through some examples.

Click

Perhaps the simplest type of interaction is clicking on a mark. Through clicking, one potential response is that we change the appearance of the mark. So from the above visualization, let’s change the color of a circle via its category:

function click_interaction_1(all_circles, hue_scale)  {
	all_circles.on('click', function(d)  {
		d3.select(this).attr('fill', d3.hcl(hue_scale(d.species),40,65))
	});
}



Click On, Click Off: classed

It is useful to think of interactions in terms of “what-ifs”:

A useful function of a selection object for realizing these interactions is classed, which can be called in 2 ways:

Here is an example of using classed for clicking individual points on and off:

function click_interaction_2(all_circles, hue_scale)  {
	all_circles.on('click', function(d)  {
		var is_classed = d3.select(this).classed('selected');
		var fill_color = is_classed ? d3.hcl(0,0,30) : d3.hcl(hue_scale(d.species),40,65)
		d3.select(this).attr('fill', fill_color).classed('selected', !is_classed);
	});
}



Category Clicking: filter

We need not just click our graphical marks! Another option is to click the rectangles in our legend corresponding to categories, so that we may select a set of circles that correspond to a given category:

function click_interaction_3(all_rect_legend, all_circles, hue_scale)  {
	all_rect_legend.on('click', function(species_type)  {
		var is_classed = d3.select(this).classed('selected');
		var fill_color = is_classed ? d3.hcl(0,0,30) : d3.hcl(hue_scale(species_type),40,65)

		all_circles.filter(d => d.species==species_type).attr('fill', fill_color)
		d3.select(this).attr('stroke', is_classed ? 'None' : 'black').classed('selected', !is_classed);
	});
}



Mouse Over

Rather than clicking, we can also change the appearance of a circle when the user mouses over a mark. This is a simple change of the event type to “mouseover”:

function mouse_interaction_4(all_circles, hue_scale)  {
	all_circles.on('mouseover', function(d)  {
		d3.select(this).attr('fill', d3.hcl(hue_scale(d.species),40,65))
	});
}



Mouse Over, Mouse Out

Importantly, we may associate selections with multiple events. So if we wanted to, say, trigger a category-based color when mousing over, and revert to the original color when mousing out, we specify two events: one for mousing over, one for mousing out.

function mouse_interaction_5(all_circles, hue_scale)  {
	all_circles.on('mouseover', function(d)  {
		d3.select(this).attr('fill', d3.hcl(hue_scale(d.species),40,65))
	});
	all_circles.on('mouseout', function(d)  {
		d3.select(this).attr('fill', d3.hcl(hue_scale(d.species),0,30))
	});
}



D3: View Navigation

We previously saw how to use a built-in D3 function – d3.zoom – for changing our viewpoint of graphical marks, e.g. panning and zooming in. However, it is also possible to customize view navigation, and one way to go about this is to directly adjust scales.

So let’s suppose we wanted to adjust the zoom level for a single scale, relative to a pressed mouse point. We can achieve this through the following sequence of operations, as applied to the wheel event (works for Macbooks!):

function mouse_interaction_axis(all_circles, axis_group, scales, rect_elem)  {
	rect_elem.on('wheel', function(d)  {
		d3.event.preventDefault();
		var delta_y = d3.event.deltaY;
		var mouse_pos = d3.mouse(this);

		var start_data = {}, end_data = {};
		start_data.x = scales.x.invert(mouse_pos[0]);
		end_data.x = scales.x.invert(mouse_pos[0]-delta_y);
		start_data.y = scales.y.invert(mouse_pos[1]);
		end_data.y = scales.y.invert(mouse_pos[1]+delta_y);

		var previous_x_domain = scales.x.domain(), previous_y_domain = scales.y.domain();
		var mouse_alpha_x = (start_data.x-previous_x_domain[0])/(previous_x_domain[1]-previous_x_domain[0]);
		var mouse_alpha_y = (start_data.y-previous_y_domain[0])/(previous_y_domain[1]-previous_y_domain[0]);

		scales.x.domain([previous_x_domain[0] - mouse_alpha_x*(end_data.x-start_data.x), previous_x_domain[1] + (1-mouse_alpha_x)*(end_data.x-start_data.x)]);
		scales.y.domain([previous_y_domain[0] - mouse_alpha_y*(end_data.y-start_data.y), previous_y_domain[1] + (1-mouse_alpha_y)*(end_data.y-start_data.y)]);

		all_circles.attr('cx', d => scales.x(d.sepalWidth))
		all_circles.attr('cy', d => scales.y(d.sepalLength))
		axis_group.xaxis.call(d3.axisBottom(scales.x));
		axis_group.yaxis.call(d3.axisLeft(scales.y));
	});
}



D3: Brushing

Clicking is not always the most convenient way to select our marks. A more convenient form of selection is brushing, where the user specifies some geometric shape as part of their interactions, and all marks that are within the shape are considered as selected.

2D Brush

D3 has support for brushing with rectangles via … the brush object. There are three main things you need to do to use brush:

Here is an example of brushing:

function mouse_brush_6(plot_group, hue_scale, width,height)  {
	var brush = d3.brush().extent([[0,0],[width,height]])

	brush.on('brush', function()  {
		var rect_select = d3.event.selection;
		plot_group.selectAll('circle')
			.filter(function()  {
				var r = +d3.select(this).attr('r');
				var visual_x = +d3.select(this).attr('cx'), visual_y = +d3.select(this).attr('cy');
				return (visual_x+r) >= rect_select[0][0] && (visual_x-r) <= rect_select[1][0] &&
					   (visual_y+r) >= rect_select[0][1] && (visual_y-r) <= rect_select[1][1];
			})
			.attr('fill', d => d3.hcl(hue_scale(d.species),40,65))
	});

	plot_group.call(brush)
}



1D Brush

D3 also has support for 1-dimensional brushing:

function mouse_brush_7(plot_group, hue_scale, width,height)  {
	var brush = d3.brushX().extent([[0,0],[width,height]])

	brush.on('brush', function()  {
		var rect_select = d3.event.selection;
		plot_group.selectAll('circle')
			.filter(function()  {
				var r = +d3.select(this).attr('r');
				var visual_x = +d3.select(this).attr('cx');
				return (visual_x+r) >= rect_select[0] && (visual_x-r) <= rect_select[1];
			})
			.attr('fill', d => d3.hcl(hue_scale(d.species),40,65))
	});

	plot_group.call(brush)
}



Brush On, Brush Off

Thus far, when we brush, the visual highlighting of marks persist. How can we limit this highlighting to a single brush event? We may simply default the fill of all circles, and then perform the update:

function mouse_brush_8(plot_group, hue_scale, width,height)  {
	var brush = d3.brush().extent([[0,0],[width,height]])

	brush.on('brush', function()  {
		var rect_select = d3.event.selection;
		var all_circs = plot_group.selectAll('circle')
		all_circs.attr('fill', d => d3.hcl(hue_scale(d.species),0,30))
		all_circs
			.filter(function()  {
				var r = +d3.select(this).attr('r');
				var visual_x = +d3.select(this).attr('cx'), visual_y = +d3.select(this).attr('cy');
				return (visual_x+r) >= rect_select[0][0] && (visual_x-r) <= rect_select[1][0] &&
					   (visual_y+r) >= rect_select[0][1] && (visual_y-r) <= rect_select[1][1];
			})
			.attr('fill', d => d3.hcl(hue_scale(d.species),40,65))
	});

	plot_group.call(brush)
}



D3: Transitions

The primary way to realize animation in D3 is through d3.transition. Transitions are used to animate selections, to go from one setting of attributes to another. In particular, transitions are frequently used in conjunction with the full data join cycle: enter, exit, and update.

Let’s consider the following example: suppose we want to collapse points that belong to a given category into a single point: the mean of the points. Conversely, we also want to take the mean point, and then expand it back to the original set of points. Consider both cases separately:

function click_mean_transition(all_rect_legend, iris_data, iris_aggregated_data, scales)  {
	all_rect_legend.on('click', function(species_type)  {
		var is_classed = d3.select(this).classed('selected');
		d3.select(this).attr('stroke', is_classed ? 'None' : 'black').classed('selected', !is_classed);
		var mean_datum = iris_aggregated_data.filter(d => d.value.species==species_type);

		if(is_classed)  {
			var iris_species = iris_data.filter(d => d.species==species_type);
			var species_all_join = d3.select('#meanplot').selectAll('.'+species_type)
				.data(iris_species, (d,i) => d.species+'-'+i);

			species_all_join.exit()
			  .transition().duration(1200)
				.attr('r', 0)
			  .remove()

			species_all_join.enter().append('circle').attr('class', d => d.species)
				.attr('fill', d => d3.hcl(scales.color(d.species),40,65)).attr('stroke', 'black').attr('stroke-width', 0.5).attr('r', 5)
				.attr('cx', scales.x(mean_datum[0].value.mean_width)).attr('cy', scales.y(mean_datum[0].value.mean_length))
				.attr('opacity', 0)
			  .transition().duration(1200)
				.attr('cx', d => scales.x(d.sepalWidth)).attr('cy', d => scales.y(d.sepalLength))
				.attr('opacity', 1)
		}
		else  {
			var species_mean_join = d3.select('#meanplot').selectAll('.'+species_type)
			  .data(mean_datum, d => d.species+'-mean');

			species_mean_join.exit()
				.transition().duration(1200)
				.attr('cx', d => scales.x(mean_datum[0].value.mean_width)).attr('cy', d => scales.y(mean_datum[0].value.mean_length))
			  .remove()

			species_mean_join.enter().append('circle').attr('class', d => d.value.species)
				.attr('fill', d => d3.hcl(scales.color(d.value.species),40,65)).attr('stroke', 'black').attr('stroke-width', 0.5)
				.attr('cx', scales.x(mean_datum[0].value.mean_width)).attr('cy', scales.y(mean_datum[0].value.mean_length))
				.attr('r', 0)
			  .transition().duration(1200)
				.attr('r', 10)
		}
	});
}