xoor logo

Mastering D3.js (part 3): Brush and Zoom

July 8th, 2018

Building charts that scale with D3.js and Canvas (Part 3)

Written by Maximiliano Duthey, Full Stack Developer @ XOOR

Welcome back! We’re thrilled to be back with a shiny new post for our series. We will continue building on top of what we did so far, so if you just ended up here somehow, I’d recommend you head over to our first 2 posts and then come back here:

Our goal on this third post is to add a cooler zoom option, where you can draw a box with your mouse cursor and zoom directly into the area within that box. This is usually called “Brush and Zoom”.

Adding some controls

The first thing we’ll do is adding new buttons to the chart. We have a few functionalities built already and it will be easier to manage them if we can activate/deactivate them with a button click. So in our index.html file we’ll add the following code:

<div class="tools">
<button id="reset">Reset</button>
<button id="zoom" class="active">Zoom</button>
<button id="brush">Brush</button>
</div>

And we add the following styles on our styles.css:

.tools button {
background-color: #e7e7e7;
border: none;
color: #000000;
cursor: pointer;
display: block;
margin-bottom: 5px;
padding: 5px 10px;
outline: 0;
}
.tools button.active {
background-color: #13a613;
}

This will put our controls aligned vertically on the right side of the chart.

The fun begins here

Time to bring those buttons to life. Let’s head over to the main JS file and at the end we’ll append the following (as always, explanation after the code):

const svgChartParent = d3.select('svg');
const zoomButton = toolsList.select('#zoom').on('click', () => {
toolsList.selectAll('.active').classed('active', false);
zoomButton.classed('active', true);
canvasChart.style('z-index', 1);
svgChartParent.style('z-index', 0);
});
const brushButton = toolsList.select('#brush').on('click', () => {
toolsList.selectAll('.active').classed('active', false);
brushButton.classed('active', true);
canvasChart.style('z-index', 0);
svgChartParent.style('z-index', 1);
});

First thing we do is getting a reference to our chart SVG part. Remember we use SVG to draw stuff like the axes and axes labels, and we have a canvas below where we draw the actual points of the scatterplot. The logic here is that some functions need to trigger events on the SVG, while others need to trigger events on the canvas. So we will play a bit with the SVG and canvas z-index property in order to make this happen. When the user clicks the zoom button, we want to make it happen on the canvas as you already learnt on the previous post. But when the user selects the brush option, we want this to put the SVG on top of the canvas. This will allow us to get access to a series of events that SVG provides and that will be useful to implement the brush function.

As you can see, we create two variables that will hold our zoom and brush button handlers. And inside the handlers we do some basic css class assignment to reflect the current active button, and also do this little trick with the z-index, putting the canvas on top of the SVG (or the other way around) while the zoom or brush functions are active.

Now it’s time to define the brush functionality. To begin, we’ll add the following piece of code to the plot.js file:

const brush = d3.brush().extent([[0, 0], [width, height]])
.on("start", () => { brush_startEvent(); })
.on("brush", () => { brush_brushEvent(); })
.on("end", () => { brush_endEvent(); })
.on("start.nokey", function() {
d3.select(window).on("keydown.brush keyup.brush", null);
});
const brushSvg = svgChart
.append("g")
.attr("class", "brush")
.call(brush);

The first thing we do is creating a brush variable using d3-brush. This function will be triggered within the “brushable” area defined by the passed extent, *in our case from the origin (0,0) on the top left corner, to the (width, height) of our chart on the bottom right corner. We then assign a some event listeners to the brushable area using the *brush.on(event, handler) [function](https://github.com/d3/d3-brush#brush_on). As you might imagine already, the “start” event will be triggered once the user starts the brush action, the “brush” event will be triggered as the user drags the mouse cursor, and finally when the user releases the mouse button, the “end” event will be triggered. This last event handler will be the one that will get the brush info, generate the transform and apply the zoom to the selected area. We will implement those handler functions soon.

The “start.nokey” event handler is needed because we don’t want to use the space, alt and shift keys on the brush event, as we will be building a brush that keeps it’s aspect ratio automatically. Feel free to experiment adding those functions back :)

We finally add a new group to the SVG which will contain the brushable area on it.

Brush event handlers

Time to code those event handlers! Let’s start with the “start” event:

let brushStartPoint = null;
function brush_startEvent() {
const sourceEvent = d3.event.sourceEvent;
const selection = d3.event.selection;
if (sourceEvent.type === 'mousedown') {
brushStartPoint = {
mouse: {
x: sourceEvent.screenX,
y: sourceEvent.screenY
},
x: selection[0][0],
y: selection[0][1]
}
} else {
brushStartPoint = null;
}
}

We define a variable called brushStartPoint. We’ll store in that variable the coordinates where the brush event started (this is, where the user clicked). As you can see we only process the “mousedown” event and what we do is storing both the window x/y position as well as the selection x/y position on the SVG. This information will be used in order to calculate the selection area later.

Now things will start to get interesting. We need to implement the “brush” event handler. This one is quite complex, so we’ll go over it in parts:

function brush_brushEvent() {
if (brushStartPoint !== null) {
const scale = width / height;
const sourceEvent = d3.event.sourceEvent;
const mouse = {
x: sourceEvent.screenX,
y: sourceEvent.screenY
};
if (mouse.x < 0) { mouse.x = 0; }
if (mouse.y < 0) { mouse.y = 0; }
let distance = mouse.y - brushStartPoint.mouse.y;
let yPosition = brushStartPoint.y + distance;
let xCorMulti = 1;
if ((distance < 0 && mouse.x > brushStartPoint.mouse.x) || (distance > 0 && mouse.x < brushStartPoint.mouse.x)) {
xCorMulti = -1;
}
...

First thing we do is defining a few variables:

  • scale: stores the relation between the chart width and height
  • sourceEvent: stores the event information
  • mouse: stores the mouse position (on the window) when the event is triggered

We need to handle the case when the mouse cursor is moved beyond the window, in those cases we will set the coordinate value to zero as we don’t want to allow negative values.

Next we define three more variables:

  • distance: will store the distance on the vertical axis. Note that if you want to store the distance on the X axis, you’ll have to calculate the scale by doing height/width instead of widht/height as we did.
  • yPosition: this is the Y axis coordinate up to which our box has been drawn (allows negative values)
  • xCorMulti: will store a correction value for the X axis coordinate

This will all make sense in a while, be patient!

Keeping the aspect ratio of the box

So the thing with the box zoom is that we want to keep the scale values in both the X and Y axes. If we draw a rectangle and our plot has a square shape, we’ll lose the ratio between the X and Y axes, and when the zoom happens our data will show up distorted.

At some point we said we would care only about the vertical movement when drawing the box, and the reason we do that is because we want to keep the aspect ratio. So if we move 10 units vertically, we want to move 10 units horizontally as well, independently of how much the user moved the cursor horizontally. But we need to pay special attention to the direction of the movement, as in some cases it will be positive and in other cases negative with respect to the event starting point. And the vertical movement being positive or negative will affect how we assign a value on the horizontal axis. Here is where our xCorMulti variable will come into play.

Let’s have a look at the following picture to understand what we’re trying to do here:

The center on the square is where the user has done the initial click of the brush event. Let’s see what happens in the four available movements the user can make after the initial click:

  1. Bottom right square Vertical axis down ->In this case we have a positive value on the vertical axis (remember that on a canvas the origin is on the top left corner and values increase downwards and to the right) *Horizontal axis right *-> We base our horizontal movement on how much the user moved vertically so that aspect ratio is kept. When the user moves to the right, horizontal values increase, so they are positive. *xCorMulti value *-> in this case will be 1, as we don’t want to change the value. If the user moved 10 units vertically, we want our box to have 10 units horizontally.
  2. Bottom left square Vertical axis down *-> Same as previous *Horizontal axis left *-> We base our horizontal movement on how much the user moved vertically so that aspect ratio is kept. When the user moves to the left, horizontal values decrease, so they are negative. *xCorMulti value -> in this case will be -1, as we want to invert the value. If the user moved 10 units vertically, we want our box to have -10 units horizontally.
  3. Top right square Vertical axis up -> In this case the vertical value will be negative Horizontal axis right -> Same as in 1, horizontal value will be positive xCorMulti value -> in this case will be -1, as we want to invert the value. If the user moved -10 units vertically, we want our box to have 10 units horizontally.
  4. Top left square Vertical axis up *-> Same as previous *Horizontal axis right -> Same as in 2, horizontal value will be negative xCorMulti value -> in this case will be 1, as we don’t want to change the value. If the user moved -10 units vertically, we want our box to have -10 units horizontally.

Hope that X correction variable makes a bit more of sense now.

Now let’s continue with the code of the *brush_brushEvent() *function

if (yPosition > height) {
distance = height - brushStartPoint.y;
yPosition = height;
} else if (yPosition < 0) {
distance = -brushStartPoint.y;
yPosition = 0;
}

This little piece is quite self explanatory. Basically if the Y position extends beyond the chart limits downwards, we calculate the distance up to the chart limit and set the Y position to be equal to the chart height. On the else branch, we take care of the case where the Y position extends beyond the chart limits on the top. In this case we set the Y position to be zero.

Now it’s time to calculate the X position based on the Y position:

let xPosition = brushStartPoint.x + distance * scale * xCorMulti;
const oldDistance = distance;
if (xPosition > width) {
distance = (width - brushStartPoint.x) / scale;
xPosition = width;
} else if (xPosition < 0) {
distance = brushStartPoint.x / scale;
xPosition = 0;
}
if (oldDistance !== distance) {
distance *= (oldDistance < 0) ? -1 : 1;
yPosition = brushStartPoint.y + distance;
}

We first compute the position on the horizontal axis using the event starting point on X and summing the distance we moved on the vertical axis multiplied by the scale (calculated at the beginning of the function) and the X correction value. We then store the distance in an oldDistance constant that we’ll use later to correct the Y position if needed. Why? Because we need to do the same checks we did on the Y axis, but on the X axis. This means we need to check if the X position moved beyond the chart limits. This might change our distance value, and since we need to keep the aspect ratio of our box, if the distance changed horizontally, we need to correct the vertical position. In this case we can be sure that the vertical position will always fall within the chart limits.

And to complete the function:

const selection = svgChart.select(".selection");
const posValue = Math.abs(distance);
selection.attr('width', posValue * scale).attr('height', posValue);
if (xPosition < brushStartPoint.x) {
selection.attr('x', xPosition);
}
if (yPosition < brushStartPoint.y) {
selection.attr('y', yPosition);
}
const minX = Math.min(brushStartPoint.x, xPosition);
const maxX = Math.max(brushStartPoint.x, xPosition);
const minY = Math.min(brushStartPoint.y, yPosition);
const maxY = Math.max(brushStartPoint.y, yPosition);
lastSelection = { x1: minX, x2: maxX, y1: minY, y2: maxY };

We first store the selection element on the SVG. This selection is the box we’ll be drawing. Then we set the box height and width using always positive values (negative height and width don’t exist, so we need to get the absolute value of the distance). Since we did all our calculations based on the vertical axis, we need to multiply the width by the originally calculated scale.

Next thing is another complex one to explain, I’ll do my best here. So basically when we draw a square/rectangle on a canvas or an SVG we need to say where it starts and what’s the width and height. The width and height was already set on the previous step as already explained. But we need to handle 2 special cases to set the rectangle starting point (which is always the top left corner). Whenever the user moves towards positive values (to the bottom or to the right side), we keep our origin on [0,0]. But if the user moved towards negative values either horizontally or vertically, our top left corner (the rectangle origin) won’t be [0,0] anymore. I recommend you to take some paper, a pencil and make some drawings to understand this better.

As an example, if we moved -10 units vertically, our rectangle origin will be on the [0, -10] point, and from that point we apply the width and height to it to draw it. Same happens if we move -10 units horizontally, moving our origin to [-10, 0] instead.

We finally calculate the max and min values for both X and Y and store that in the lastSelection object. With this code written, our quest to build a box that keeps the same aspect ratio of our chart can be considered successful. BUT! we still need to work on the zoom part, otherwise we’ll be drawing boxes that do nothing.

The brush end event

function brush_endEvent() {
const s = d3.event.selection;
if (!s && lastSelection !== null) {
// Re-scale axis for the last transformation
let zx = lastTransform.rescaleX(x);
let zy = lastTransform.rescaleY(y);
// Calc distance on Axis-X to use in scale
let totalX = Math.abs(lastSelection.x2 - lastSelection.x1);
// Get current point [x,y] on canvas
const originalPoint = [zx.invert(lastSelection.x1), zy.invert(lastSelection.y1)];
// Calc scale mapping distance AxisX in width * k
// Example: Scale 1, width: 830, totalX: 415
// Result in a zoom of 2
const t = d3.zoomIdentity.scale(((width * lastTransform.k) / totalX));
// Re-scale axis for the new transformation
zx = t.rescaleX(x);
zy = t.rescaleY(y);
// Call zoomFunction with a new transformation from the new scale and brush position.
// To calculate the brush position we use the originalPoint in the new Axis Scale.
// originalPoint it's always positive (because we're sure it's within the canvas).
// We need to translate this originalPoint to [0,0]. So, we do (0 - position) or (position * -1)
canvasChart
.transition()
.duration(200)
.ease(d3.easeLinear)
.call(zoom_function.transform,
d3.zoomIdentity
.translate(zx(originalPoint[0]) * -1, zy(originalPoint[1]) * -1)
.scale(t.k));
lastSelection = null;
} else {
brushSvg.call(brush.move, null);
}
}

The first thing we need to do is clearing the selection from the event. If we don’t do this, our drawn box would stay there on the chart and we don’t want that. All we want the box for is to give a nice UX so that the users can see what’s the selection they’re doing. Once they release the mouse button, we want the box to dissappear and make the zoom action to the box boundaries.

Then we use the last transformation that was applied (see the previous post if you don’t remember how transformations work) to re-scale both X and Y axes. The next step is to calculate the total horizontal distance of our selection. We do this with the min and max X positions.

As we know, on [x1, y1] we have the top left corner of our selection. What we’ll do is transform those x1 and y1 values which are pixels, into values that make sense within our canvas chart scales. We do this using the invert function of the re-scaled axes.

Next thing to do is calculate the new transformation for our chart. Remember we’re zooming in, and zooming translates into a transformation on the chart. Having the transformation set, we proceed to re-scale our axes again using the “zoomed” transformation.

Now we’re ready to do our cool transition on the canvas so that the view is focused on the area drawn by the user. We pass the event what will be our new origin (top left corner) and the way we do that is using the calculated X and Y positions and translating to those positions multiplied by -1. The reason is simple, let’s say our new origin is on position [10, 10], in order to move it to [0, 0] we need to translate that position by [-10, -10].

And now YES, we’re done! we have a pretty nice box zoom function implemented.

Thanks for reading and hope things were clear… if not feel totally free to write a comment or reach us by email at hello@xoor.io.

Don’t miss our posts, follow us now on Twitter!

Share this article

Comment on Medium.com