Any hints how to control d3 brush/zoom with keyboard: 1. Ability to focus on brush control 2. Ability to change brush area using keyboard
Is it supported out of the box?
Update: Apparently there is no out of the box solution (hope d3 will provide it at some point). It means that a custom solution will depend on visualization/scenario. Posting actual UX and requirements and will provide a solution for this particular case.
In order to meet accessibility requirements the task was to modify below chart control to be able to zoom/brush using keyboard. This includes: 1) being able to set focus; 2) being able to control using left and right arrow keys.
I'm going to use this bl.ock as a reference. I believe it is the source of your image.
Zoom and Brush Function Comparison
We are interested in a couple things in this block, the code for zooming and the code for brushing:
function brushed() {
if (d3.event.sourceEvent && d3.event.sourceEvent.type === "zoom") return; // ignore brush-by-zoom
var s = d3.event.selection || x2.range();
x.domain(s.map(x2.invert, x2));
focus.select(".area").attr("d", area);
focus.select(".axis--x").call(xAxis);
svg.select(".zoom").call(zoom.transform, d3.zoomIdentity
.scale(width / (s[1] - s[0]))
.translate(-s[0], 0));
}
function zoomed() {
if (d3.event.sourceEvent && d3.event.sourceEvent.type === "brush") return; // ignore zoom-by-brush
var t = d3.event.transform;
x.domain(t.rescaleX(x2).domain());
focus.select(".area").attr("d", area);
focus.select(".axis--x").call(xAxis);
context.select(".brush").call(brush.move, x.range().map(t.invertX, t));
Both functions:
The differences are important:
The brush function updates the scale using d3.zoomIdentity, it must do this as it needs to update the zoom function to reflect the current zoom scale and transform.
The zoom function manually sets the brush, it must do this because the brush needs to be updated.
Zoom In and Brush "In" Without Events
To control this by the keyboard, it is probably easier to use the brushed() function as a template. This is because the current zoom transform can be tricky to retrieve whereas it is relatively easy to spoof a change in the brush.
In the brushed function the value in d3.event.selection is an array containing the range of values contained by the brush (values in the range, not the domain). It is an array of the minimum and maximum range values in the reference/context scale, x2, covered by the brush. This is the only thing we need to update both zoom and brush.
To zoom in, we can take the focus x scale's domain and find the domain's minimum and maximum values. Then we can re-set the focus x scale's domain to be slightly smaller, zooming in effectively. The code below converts the domain to a range, and reduces that range before converting it back into a domain - this is unneeded but follows the brushed() function more closely and means not having to deal with dates.
var xMin = x2(x.domain()[0]);
var xMax = x2(x.domain()[1]);
var currentDifference = Math.abs(xMin-xMax);
xMin += currentDifference / 2 / 3 // increase the minimum value of the domain
xMax -= currentDifference / 2 / 3 // decrease the maximum value of the domain
x.domain([xMin,xMax].map(x2.invert, x2));
We can also set the zoom scale like so:
var identity = d3.zoomIdentity
.scale(width/ (xMax - xMin))
We also want to modify the zoom's transform so we are zooming in to the center of the previous larger domain. The following is just a reproduction of the code used in the example block, but with clearer names for the sake of illustration:
var identity = d3.zoomIdentity
.scale(width/ (xMax - xMin))
.translate(-xMin, 0);
If we use the brushed function as a template, we might end up with:
var xMin = x2(x.domain()[0]); // minimum value in x range currently
var xMax = x2(x.domain()[1]); // maximum value in x range currently
var currentDifference = Math.abs(xMax-xMin); // center point of range
xMin += currentDifference / 2 / 3 // reduce the distance between center point and end points
xMax -= currentDifference / 2 / 3
x.domain([xMin,xMax].map(x2.invert, x2)); // convert the range to a domain
focus.select(".area").attr("d", area); // redraw the chart
focus.select(".axis--x").call(xAxis); // redraw the axis
var identity = d3.zoomIdentity
.scale(width/ (xMax - xMin))
.translate(-xMin, 0); // update the zoom factor
context.select(".brush").call(brush.move, x.range().map(identity.invertX, identity)); // update the brush
svg.select(".zoom").call(zoom.transform, identity); // apply the zoom factor
This will zoom the focus area in to an area centered in the current domain's center. The domain will shrink by one third with the code above, but that can be changed to match your needs.
The only real differences compared to the original brushed function are that we are:
That is it.
Other Operations
You can zoom out by expanding rather than shrinking the domain, just switch the sign when defining the new end points:
xMin -= currentDifference / 2 / 3
xMax += currentDifference / 2 / 3
Moving left would look like:
xMin -= currentDifference / 2 / 3
xMax -= currentDifference / 2 / 3
And moving right naturally would be the opposite.
Adding the Keyboard
Now all you have to do is set up a listener to listen for key strikes:
d3.select("body")
.on("keypress", function() {
if (d3.event.key == "a") {
// one of zoom in/out/pan
}
else if (d3.event.key == "b" {
//...
}
});
Putting it All Together
I've assembled a block that shows it all together, I've used asdw for key inputs:
One last note: I've included a check to make sure that the new domain is in bounds: we don't want to zoom beyond the domain of our data.
Here's the example.
Since SVG 2.0 is not here yet and in SVG 1.0 focusable elements are not supported I ended up using <a xlink:href="#">
trick to get a focus on left/right ticks. Also decided that getting a focus for the whole brush element is not necessarily because proper range can be achieved by moving left/right ticks.
private createResizeTick(resizeClass: string, id: string, brushTickClass: string, tickIndex: number, bottom: number) {
let self = this;
// +++++++++++++++++++ NEW CODE +++++++++++++++++++
let aElement = this._xBrushElement.selectAll(resizeClass)
.append('a')
.attr('id', id)
.attr('xlink:href', '#')
.on('keydown', () => {
if ((<KeyboardEvent>d3.event).keyCode !== 37 && (<KeyboardEvent>d3.event).keyCode !== 39) {
return;
}
// A function which adjusts brush's domain (specific to data model)
self.brushKeyMove((<KeyboardEvent>d3.event).keyCode, tickIndex);
})
.on('keyup', () => {
if ((<KeyboardEvent>d3.event).keyCode !== 37 && (<KeyboardEvent>d3.event).keyCode !== 39) {
return;
}
self.brushOnEnd(); // A function which already processes native onBrushEnd event
document.getElementById(id).focus();
});
// --------------- END OF NEW CODE ---------------
aElement.append('text')
.attr('class', 'brushtick ' + brushTickClass)
.attr('transform', 'translate(0,' + bottom + ')')
.attr('x', 0)
.attr('y', 6)
.attr('dy', '0.35em');
}
Here is the result:
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With