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:
- The first argument is the interaction type. We will go through the most common below, but please go here for a more complete reference.
- The second argument is an anonymous function. Much like with
attr
, D3 will populate the data, index, and group for each element in the selection.
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”:
- What if the circle that a user clicked on has not previously been clicked? Let’s change the color based on the category.
- What if the circle that a user clicked on already been clicked? Let’s change the color back to the default.
A useful function of a selection object for realizing these interactions is classed
, which can be called in 2 ways:
- If called with one argument, namely a string representing a class name, it will return a boolean of whether or not the element has this class name as an attribute. This is typically used when the selection is of a single element.
- If called with two arguments, namely a string for the class name and a boolean, then for each element in the selection it will either create a class with the given name, or remove the class, if the boolean is true or false, respectively. The second argument can also be an anonymous function, which returns a boolean, where you can use the data bound to the element in determining whether to create/remove the class.
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!):
- Record the mouse position, the data value (via the scale) that corresponds to this position (invert: go from visual range to data domain).
- Obtain the amount by which we have changed the wheel, use this to compute an offset for our mouse position, and compute a zoom amount relative to the previous and new mouse positions.
- Update both our scale and mark positions.
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
:
- Create the brush object. Additionally, it is useful to tell the brush its geometric extent.
- Call
on
on the brush to specify brush behavior. There are three main behaviors: “start”, which is the start of a brush, “brush” which is the process of brushing, and “end”, which is when the user has stopped brushing. Within your specified anonymous function, you may access the brush’s rectangle coordinates viad3.event.selection
, and then test whether your marks are contained within the rectangle. - Last, you need to associate a group element with the brush, achieved via
call
. Importantly: the brush’s rectangle coordinates are within the local coordinate system of the group element.
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:
- For collapsing points, we perform a data join, composed of a single element: the datum representing the mean. We are then interested in both the enter and exit selections. The exit selection corresponds to all of the points (excluding the mean). We invoke a
transition()
on the exit selection, and then tell the circles where to go: the mean. Note: the circles know how to move to the mean, because they already have their positions set from the original data join. Once the transition finishes, we remove them from the DOM. The enter selection corresponds to the mean: we need to first initialize it, for attributes both involved and not involved in the transition. We then invoke transition, and then tell the mean how to transition: in this case, we grow its radius. - For expanding points we perform a similar, albeit inverse, process. The exit selection now corresponds to the mean point, where we simply shrink its radius to 0. The enter selection now corresponds to all of the points, where we need to initialize their attributes (where they are coming from), invoke transition, and then specify new positions and radii (where they are going to).
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)
}
});
}