D3: Interactions in Juxtaposed Plots
For juxtaposed plots, we need to consider how the two views are related:
- What are shared attributes, if any?
- Are we aggregating data between views?
This helps determine how an interaction in one view could potentially impact the other view.
Let’s look at a simple example for 2 views that come from Assignment 3:
- The left view is our scatterplot, where each circle is a day, encoding temperature and wind.
- The right view is our bar plot, showing the average minimum and maximum for each month.
- The interaction begins with the scatterplot: upon selecting points via brushing, the right view is updated. Specifically, only the points that have been selected will be used for aggregating data.
The example below shows how to use data filtering – rather than element filtering – to both update our scatterplot view, as well as compute our averages which are then used to update the bar plot. Updating the bar plot, in particular, involves each stage of the data join: exit, enter, and update.
function weather_interaction(svg, seattle_data, scales, nester) {
var brush = d3.brush().extent([[0,0],[scales.x.range()[1],scales.y.range()[0]]])
brush.on('brush', function() {
svg.select('#windtemps').selectAll('circle').attr('fill', '#222').attr('opacity', 0.12)
var rect_select = d3.event.selection;
var brushed_data = seattle_data.filter(d => {
var r = scales.size(d.temp_high-d.temp_low);
var visual_x = scales.x(d.wind), visual_y = scales.y(.5*(d.temp_low+d.temp_high));
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];
});
svg.select('#windtemps').selectAll('circle')
.data(brushed_data, d => d.key).attr('fill', d3.hcl(100,50,67)).attr('opacity', 0.4)
var data_by_month = nester.entries(brushed_data);
var data_join = svg.select('#timetemps').selectAll('.bar').data(data_by_month, d => d.key)
data_join.exit().remove()
data_join = data_join.enter().append('rect')
.attr('fill', d3.hcl(100,50,67)).attr('class', 'bar')
.merge(data_join)
.attr('x', d => scales.time(new Date(2012,d.key,1))).attr('width', scales.time.bandwidth())
.attr('y', d => scales.y(d.value.ave_high)).attr('height', d => scales.y(d.value.ave_low) - scales.y(d.value.ave_high))
});
svg.select('#circleplot').call(brush)
}
D3: Interactions in Small Multiples
Hovering for detail
We can make certain assumptions about how to perform interactions when we are working with small multiples. Recall the following:
- All views correspond to the same data attributes.
- Each view shows a different partitioning of the data.
Due to the consistency between views, in terms of attributes, it is natural to simply replicate an interaction from one view, into all views.
This is quite easy to achieve with D3, assuming that your selections are properly set up. We need to perform the following:
- Select all plots, such that they are all listening for events.
- If an event is triggered by a plot, determine the interaction for that individual plot.
- Apply this interaction to all plots.
Zooming for detail
Linked zooming can also be easily achieved, using a similar strategy:
function sm_zoom(plot_groups, axis_groups, scales, band_partitioner, band_area) {
var min_year = scales.x.domain()[0].getFullYear(), max_year = scales.x.domain()[1].getFullYear();
var year_scale = d3.scaleQuantize().domain([-4,4]).range([-3,-2,-1,0,1,2,3]);
var rect_selection = plot_groups.append('rect')
.attr('fill', 'None').attr('pointer-events', 'all').attr('width', scales.x.range()[1]).attr('height', scales.y.range()[0])
d3.select('#svgzoom').append('clipPath').attr('id', 'clip')
.append('rect').attr('width', scales.x.range()[1]).attr('height', scales.y.range()[0])
plot_groups.selectAll('.areaplot').attr('clip-path', 'url(#clip)');
rect_selection.on('wheel', function() {
d3.event.preventDefault();
var delta_y = d3.event.deltaY;
var mouse_pos = d3.mouse(this);
var previous_x_domain = scales.x.domain();
var start_year = previous_x_domain[0].getFullYear(), end_year = previous_x_domain[1].getFullYear();
var start_data = scales.x.invert(mouse_pos[0]).getFullYear(), end_data = scales.x.invert(mouse_pos[0]-delta_y).getFullYear();
var data_diff = end_data-start_data;
var start_alpha = (start_data-start_year)/(end_year-start_year), end_alpha = (1-start_alpha);
var start_year_increment = year_scale(data_diff*start_alpha), end_year_increment = year_scale(data_diff*end_alpha);
var new_year_min = new Date(Math.max(min_year,start_year-start_year_increment),0);
var new_year_max = new Date(Math.min(max_year,end_year+end_year_increment),0);
scales.x.domain([new_year_min,new_year_max])
plot_groups.selectAll('.areaplot')
.attr('d', d => {
var b_l = d.values[0].band_level;
var min_band_datum = band_partitioner(b_l), max_band_datum = min_band_datum+band_partitioner.bandwidth();
scales.y.domain([min_band_datum,max_band_datum])
band_area
.y0(d => scales.y(min_band_datum))
.y1(d => scales.y(Math.min(d.gdp,max_band_datum)))
return band_area(d.values);
})
axis_groups.call(d3.axisBottom(scales.x).ticks(5));
});
}
D3: Selections in Parallel Coordinates
As discussed, brushing is a natural form of selection in parallel coordinates. We brush attributes, and this triggers the selection of full polylines.
Implementing this in D3 is pretty straightforward:
- We first associate a group with each attribute.
- Create our brush.
- Declare what we want to do with a brush event. For parallel coordinates, we simply need to determine whether a data’s attribute value is within the data domain selected by the brush.
- Add the brush to all groups. By doing this, we are now associating a brush with all attributes.
function pcp_interaction(svg, attributes, scales, min_y, max_y, nba_data) {
var x_pad = 10;
svg.selectAll('empty').data(attributes).enter().append('g').attr('class', 'handle')
.attr('transform', d => 'translate('+(scales.x(d))+','+(min_y)+')')
var pcp_brush = d3.brushY().extent([[-x_pad,0],[x_pad,max_y-min_y]])
pcp_brush.on('brush', function(att) {
var rect_select = d3.event.selection;
var min_d = scales.y[att].invert(min_y+rect_select[1]);
var max_d = scales.y[att].invert(min_y+rect_select[0]);
var brushed_data = nba_data.filter(d => {
return d[att] >= min_d && d[att] <= max_d;
});
var data_join = svg.selectAll('.polyline').data(brushed_data, d => d.Name)
data_join.attr('stroke', d3.hcl(210,40,60)).attr('stroke-width', 3).attr('stroke-opacity', 0.2)
data_join.exit().attr('stroke', d3.hcl(30,60,75)).attr('stroke-width', 2).attr('stroke-opacity', 0.12)
});
d3.selectAll('.handle').call(pcp_brush)
}
There are many variations to brushing with parallel coordinates, with the above only showing perhaps the simplest option – how else would you design interactions for parallel coordinate plots?
D3: Selections in Scatterplot Matrices
Brushing in scatterplot matrices is intended to highlight a single data item in all views via brushing the data’s circle in one view. This is conceptually similar to brushing in juxtaposed plots:
- Brush in a single view to obtain the data items that have been selected.
- Update the remaining views.
Note in scatterplot matrices that we are duplicating data items across views, but each view is showing only a pair of data attributes. Hence, at the level of a data item, we have a correspondence between circles. Thus, we may perform a data join using keys – in this case, each key corresponds to each player’s name:
- First select all groups that correspond to the plots.
- Next select all circles – a set of circles for each plot. You can use
classed
to help minimize the number of points that are necessary to select, e.g. only select points that have already been brushed. - Then perform a data join – this will match the data that has been selected with each plot. We simply set the fill color, which is dependent on whether we are in the exit selection (revert back) or the returned data join (what was matched, and hence should be updated to reflect a selection).
function splom_interaction(size, all_data, x_quantitative_scales, y_quantitative_scales) {
var brush = d3.brush().extent([[0,0],[size,size]])
brush.on('start', function(d,i) {
d3.selectAll('.splom').selectAll('.selection').attr('style', 'display: none;')
d3.selectAll('.splom').selectAll('.handle').attr('style', 'display: none;')
})
brush.on('brush', function(d,i) {
var rect_select = d3.event.selection;
var x_scale = x_quantitative_scales[d[0]], y_scale = y_quantitative_scales[d[1]];
var min_data_x = x_scale.invert(rect_select[0][0]), min_data_y = y_scale.invert(rect_select[1][1])
var max_data_x = x_scale.invert(rect_select[1][0]), max_data_y = y_scale.invert(rect_select[0][1])
var selected_data = all_data.filter(player => {
return player[d[0]] >= min_data_x && player[d[1]] >= min_data_y && player[d[0]] <= max_data_x && player[d[1]] <= max_data_y;
});
d3.selectAll('.splom').selectAll('.pointselection').data(selected_data, player => player.id).exit()
.classed('pointselection', false).attr('fill', d3.hcl(20,60,70))
d3.selectAll('.splom').selectAll('circle').data(selected_data, player => player.id)
.classed('pointselection', true).attr('fill', d3.hcl(200,60,70))
});
d3.selectAll('.splom').call(brush)
}