Rebroadcasting: I originally wrote this article on May 30, 2013 for the Emcien Engineering blog.

I wanted to make a D3 brush that looked a little nicer than a colored rectangle overlaying a chart. This is what I came up with:


Lets walk through building this, step by step

Part 1 The standard brush

The first thing we are going to do is create the initial chart and brush:

var data = [{  
    date: new Date("Jan 01, 2013"),
    data: 12
},{
    date: new Date("Jan 02, 2013"),
    data: 17
// ...

data.js

function run() {  
    var w = 750;
    var h = 100;

    var x = d3.time.scale()
        .range( [0, w] )
        .domain( [data[0].date, data[data.length-1].date] );

    var y = d3.scale.linear()
        .range( [h, 0] )
        .domain( [0, 20] );

    var svg = d3.select("body").append("svg");
    var focus = svg.append("g");

    var line = d3.svg.line()
        .interpolate("basis")
        .x(function(d){ return x(d.date); })
        .y(function(d){ return y(d.data); });

    var area = d3.svg.area()
        .interpolate("basis")
        .x(function(d){ return x(d.date); })
        .y1(function(d){ return y(d.data); })
        .y0(function(d){ return y(0); });

    var brush = d3.svg.brush().x(x);

    focus.append("path")
        .attr("class", "area")
        .style({"fill": "#ccc"})
        .datum(data)
        .attr("d", area);

    focus.append("path")
        .attr("class", "line")
        .style({
            "fill": "none",
            "stroke": "#000",
            "stroke-width": "2"
        })
        .datum(data)
        .attr("d", line);

    focus.append("g")
        .attr("class","x brush")
        .call(brush.extent([data[7].date,data[11].date]))
        .selectAll("rect")
        .attr("height", h)
        .style({
            "fill": "#69f",
            "fill-opacity": "0.3"
        });
}

main.js

Nothing new here, this just creates a chart with a standard brush:



Part 2 The mask

Now to get my effect I needed to create a mask. This little prototype I wrote does the trick pretty nicely:

var SVGMask = (function() {

    function SVGMask(focus) {
        this.focus = focus;
        this.mask  = this.focus.append("g").attr("class", "mask");
        this.left  = this.mask.append("polygon");
        this.right = this.mask.append("polygon");
        this._x = null;
        this._y = null;
    }

    SVGMask.prototype.style = function(prop, val) {
        this.left.style(prop, val);
        this.right.style(prop, val);
        return this;
    }

    SVGMask.prototype.x = function(f) {
        if (f == null) {
            return this._x;
        }
        this._x = f;
        return this;
    };

    SVGMask.prototype.y = function(f) {
        if (f == null) {
            return this._y;
        }
        this._y = f;
        return this;
    };

    SVGMask.prototype.redraw = function() {
        var lp, maxX, maxY, minX, minY, rp, xDomain, yDomain;

        yDomain = this._y.domain();
        minY = yDomain[0];
        maxY = yDomain[1];

        xDomain = this._x.domain();
        minX = xDomain[0];
        maxX = xDomain[1];

        lp = {
            l: this._x(minX),
            t: this._y(minY),
            r: this._x(this.from),
            b: this._y(maxY)
        };

        rp = {
            l: this._x(this.to),
            t: this._y(minY),
            r: this._x(maxX),
            b: this._y(maxY)
        };

        this.left.attr("points", "" + lp.l + "," + lp.t + "  " + lp.r + "," + lp.t + "  " + lp.r + "," + lp.b + "  " + lp.l + "," + lp.b);
        this.right.attr("points", "" + rp.l + "," + rp.t + "  " + rp.r + "," + rp.t + "  " + rp.r + "," + rp.b + "  " + rp.l + "," + rp.b);

        return this;
    };

    SVGMask.prototype.reveal = function(extent) {
        this.from = extent[0];
        this.to = extent[1];
        this.redraw();
        return this;
    };

    return SVGMask;

})();

mask.js


The way this mask works is, you give it something to mask; then specify the domain using the x() and y() methods. You can then reveal a portion by calling the reveal() method and passing an extent. This is very similar to calling extent() on the brush. Only instead of specifying the area that the brush should cover, it specifies an area not to cover.


Now just need to update main.js to make use of this mask:

var mask = new SVGMask(focus)  
    .x(x)
    .y(y)
    .style("fill","red")
    .reveal([data[8].date,data[14].date]);

This snippet needs to be put between where the area and line are added to the screen. This ensures that the mask is in the proper position in the stack to cover what we want and only what we want. The result is this:

Notice how the gray area is being masked while the black line is not. I only temporarily set the mask to red to make it clear what is happening.


Now we just need to make sure that the portion of the area being revealed by the mask lines up to the extent of the brush. So we update main.js with this snippet:

brush.on("brush", function(){  
    mask.reveal(brush.extent());
});

Now the mask follows the brush quite nicely:

Try moving the brush around to see how the mask stays aligned to it.

And if we get rid of that red fill on our mask we have this:



Part 3 Final touches

Now to make this effect complete we need to add our left and right handles:

var leftHandle = focus.append("image")  
    .attr("width", 15)
    .attr("height", 100)
    .attr("x", x(data[7].date)-5)
    .attr("xlink:href", 'left-handle.png');

var rightHandle = focus.append("image")  
    .attr("width", 15)
    .attr("height", 100)
    .attr("x", x(data[11].date)-7)
    .attr("xlink:href", 'right-handle.png');

And we also need to update our brush event to keep them in sync:

brush.on("brush",function(){  
    var ext = brush.extent();
    mask.reveal(ext);
    leftHandle.attr("x", x(ext[0])-5);
    rightHandle.attr("x", x(ext[1])-7);
});

Finally we will want to hide the actual brush itself:

focus.append("g")  
    .attr("class", "x brush")
    .call(brush.extent([data[7].date,data[11].date]))
    .selectAll("rect")
    .attr("height", h)
    .style({"fill": "none"});

And now we have our final result:

Thats it. We now have a much better looking brush. I hope you enjoyed this tutorial, you can get the final source code here.

© 2017. All Rights Reserved.

Proudly published with Ghost