Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Accessibility: d3 brush/zoom can get focus and be controlled with keyboard

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?

enter image description here

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.

enter image description here

like image 874
ZakiMa Avatar asked Nov 14 '17 22:11

ZakiMa


2 Answers

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:

  • check to see if the main body of the function should be executed
  • set a new x scale domain
  • update the area and axis

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:

  • manually computing the brush extent
  • updating the brush with the method used in the zoomed function.

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:

  • a: pan left
  • d: pan right
  • w: zoom in
  • s: zoom out.

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.

like image 96
Andrew Reid Avatar answered Nov 18 '22 17:11

Andrew Reid


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:

enter image description here

like image 21
ZakiMa Avatar answered Nov 18 '22 18:11

ZakiMa