Brush Interaction in D3
Use cases for and implementation of brush events
- When we are interactively defining a graphical query, it’s often useful to be able to specify a range of values of interest. For example, brushes can be used for dynamically linking two plots, like in these linked scatterplots,
or this linked time series plot,
Brushing can also be used to implement the focus + context principle through a zoom widget,
- We learned how to create a brush in these earlier notes, but
we never discussed how to use a brush to define a query. Unfortunately, unlike
Shiny, D3 has no function to return the indices of the samples lying under the
brush. Instead, we will manually extract the pixel coordinates of the associated
brush and then relate them to the original data values. To access these
underlying brush coordinates, we can use the brush event’s
selection
attribute, which is the approach in thebike.js
time series example above,
let brush = d3.brush()
.extent([[0, 0], [300, 300]])
.on("brush", ev => brush_update(ev, data, scales));
function brush_update(ev, data, scales) {
let dates = filter_dates(ev, data, scales);
...
function filter_dates(ev, data, scales) {
let [[x0, y0], [x1, y1]] = ev.selection;
or we can use the d3.brushSelection
function to operate directly on the brush
object, which is the approach in the penguins.js
linked scatterplot example,
since there were several brushes to handle simultaneously,
// get selection in current brush b
let node = d3.select(`#brush${b}`).node()
let [[x0, y0], [x1, y1]] = d3.brushSelection(node)
- To use these pixel coordinates to define a queries, we have to relate our
original data values with pixel coordinates. Fortunately, continuous scales are
associated with an
.invert()
method which maps pixel coordinates back to the original data domain. For example, in the bike example, we get[[x0, y0], [x1, y1]]
coordinates specifying the brush’s bounding box in the original temperature and humidity dimensions. We then loop over every point in the dataset to see whether it is contained within these bounds.
function filter_dates(ev, data, scales) {
// relate pixel with original data coordinates
let [[x0, y0], [x1, y1]] = ev.selection;
x0 = scales.x.invert(x0);
y0 = scales.y.invert(y0);
x1 = scales.x.invert(x1);
y1 = scales.y.invert(y1);
// check which time series belong within the scatterplot range
let dates = [];
for (let i = 0; i < data.scatter.length; i++) {
let di = data.scatter[i]
if (di.temp > x0 && di.hum < y0 && di.temp < x1 && di.hum > y1) {
dates.push(di.dteday)
}
}
return dates
}
The same inversion trick is used in the penguins example (see lines 54 - 67).
- We can compose brush queries in original ways. For example, in the example below, we’re highlighting points if they lie within both brush selections (not the either brush, like in our previous example). This is implemented by looping over datapoints and checking whether they lie in the data ranges specified by both brushes (lines 53 - 71 in penguins2.js).
- For our zooming example, we use a slightly different approach from either of
the previous two. Rather than defining arrays describing which points have been
selected, we use the brush to redefine the scales that define the main
scatterplot. Specifically, recall that every scale has to map a domain in the
data space to a range in the pixel space. By changing which data values we
consider the upper and lower limits of the domain, we can map smaller (or
larger) zones of the data space to the same range in the pixel space. This is
done by finding the current brush extent in the data space,
function brush_update(ev, scales) { let [[x0, y0], [x1, y1]] = ev.selection; x0 = scales.x_zoom.invert(x0) y0 = scales.y_zoom.invert(y0) x1 = scales.x_zoom.invert(x1) y1 = scales.y_zoom.invert(y1) ...
and then regenerating the
x
andy
scales to have a domain reflecting the brush selection,
function brush_update(ev, scales) {
// update the scales
let new_scale = make_scales([[x0, y0], [x1, y1]])
// update the circle positions using the new scales
update(new_scale)
...
function make_scales(extent) {
return {
x: d3.scaleLinear()
.domain([extent[0][0], extent[1][0]]) // updates domain using brush X
.range([0, width]),
y: d3.scaleLinear()
.domain([extent[0][1], extent[1][1]]) // updates domain using brush Y
.range([0, height]),
...