D3: Interactions in Juxtaposed Plots

For juxtaposed plots, we need to consider how the two views are related:

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 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:

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:

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:

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:

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:

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)
}