Question

I am trying to get brushing to work similar to this example, but with a grouped bar chart: http://bl.ocks.org/mbostock/1667367

I don't really have a good understanding of how brushing works (I haven't been able to find any good tutorials), so I'm a bit at a loss as to what is going wrong. I will try to include the relevant bits of code below. The chart is tracking the time to fix broken builds by day and then grouped by portfolio. So far the brush is created and the user can move and drag it, but the bars in the main chart are re-drawn oddly and the x axis is not updated at all. Any help you can give would be greatly appreciated. Thank you.

// x0 is the time scale on the X axis
var main_x0 = d3.scale.ordinal().rangeRoundBands([0, main_width-275], 0.2);
var mini_x0 = d3.scale.ordinal().rangeRoundBands([0, main_width-275], 0.2);

// x1 is the portfolio scale on the X axis
var main_x1 = d3.scale.ordinal();
var mini_x1 = d3.scale.ordinal();

// Define the X axis
var main_xAxis = d3.svg.axis()
    .scale(main_x0)
    .tickFormat(dateFormat)
    .orient("bottom");

var mini_xAxis = d3.svg.axis()
    .scale(mini_x0)
    .tickFormat(dateFormat)
    .orient("bottom");

After binding the data...

// define the axis domains
main_x0.domain(data.result.map( function(d) { return d.date; } )
    .sort(d3.ascending));
mini_x0.domain(data.result.map( function(d) { return d.date; } )
    .sort(d3.ascending));

main_x1.domain(data.result.map( function(d) { return d.portfolio; } )
    .sort(d3.ascending))
    .rangeRoundBands([0, main_x0.rangeBand() ], 0);
mini_x1.domain(data.result.map( function(d) { return d.portfolio; } )
    .sort(d3.ascending))
    .rangeRoundBands([0, main_x0.rangeBand() ], 0);

// Create brush for mini graph
var brush = d3.svg.brush()
  .x(mini_x0)
  .on("brush", brushed);

After adding the axis's, etc.

// Create the bars
var bar = main.selectAll(".bars")
  .data(nested)
.enter().append("g")
  .attr("class", function(d) { return d.key + "-group bar"; })
  .attr("fill", function(d) { return color(d.key); } );

bar.selectAll("rect").append("rect")
  .data(function(d) { return d.values; })
.enter().append("rect")
  .attr("class", function(d) { return d.portfolio; })
  .attr("transform", function(d) { return "translate(" + main_x0(d.date) + ",0)"; })
  .attr("width", function(d) { return main_x1.rangeBand(); })
  .attr("x", function(d) { return main_x1(d.portfolio); })
  .attr("y", function(d) { return main_y(d.buildFixTime); })
  .attr("height", function(d) { return main_height - main_y(d.buildFixTime); });

Here is the brush function (trying several different options)...

function brushed() {
    main_x1.domain(brush.empty() ? mini_x1.domain() : brush.extent());

    //main.select("rect")
      //.attr("x", function(d) { return d.values; })
      //.attr("width", function(d) { return d.values; });
    bar.select("rect")
      .attr("width", function(d) { return main_x1.rangeBand(); })
      .attr("x", function(d) { return main_x1(d.portfolio); });
      //.attr("y", function(d) { console.log(d); return main_y(d.buildFixTime); })
      //.attr("height", function(d) { return main_height - main_y(d.buildFixTime); });

    main.select(".x.axis").call(main_xAxis);
}
Was it helpful?

Solution

The problem comes from trying to use the brush to set the x-scale domain, when your x-scale is an ordinal scale. In other words, the expected domain of your x-axis is a list of categories, not a max-min numerical extent. So the problem is right at the top of the brushing function:

function brushed() {
    main_x0.domain(brush.empty() ? mini_x0.domain() : brush.extent());

The domain set by brush.extent() is an array of two numbers, which then completely throws off your ordinal scale.

According to the wiki, if one of the scales attached to a brush function is an ordinal scale, the values returned by brush.extent() are values in the output range, not in the input domain. Ordinal scales don't have an invert() method to convert range values into domain values.

So, you have a few options on how to proceed:

You could re-do the whole graph using a linear time scale for your main x-axes instead of an ordinal scale. But then you have to write your own function to figure out the width of each day on that axis instead of being able to use .rangeBand().

You can create your own "invert" function to figure out which categorical values (dates on the mini_x0.domain) are included in the range returned by brush.extent(). Then you would have to both reset the main_x0.domain to only include those dates on the axis, and filter out your rectangles to only draw those rectangles.

Or you can leave the domain of main_x0. be, and change the range instead. By making the range of the graph larger, you space out the bars greater. In combination with a clipping path to cut off bars outside the plotting area, this has the effect of only showing a certain subset of bars, which is what you want anyway.

But what should the new range be? The range returned by brush.extent() is the beginning and end positions of the brushing rectangle. If you used these values as the range on the main graph, your entire graph would be squished down to just that width. That's the opposite of what you want. What you want is for the area of the graph that originally filled that width to be stretched to fill the entire plotting area.

So, if your original x range is from [0,100], and the brush covers the area [20,60], then you need a new range that satisfies these conditions:

  • the 20% mark of the new range width is at 0;
  • the 60% mark of the new range width is at 100.

Therefore,

  • the total width of the new range is ( (100-0) / (60-20) )*(100-0) = 250;
  • the start of the new range is at (0 - (20/100)*250) = -50;
  • the end of the new range is at (-50) + 250 = 200.

Now you could do all the algebra for figuring out this conversion yourself. But this is really just another type of scaling equation, so why not create a new scale function to convert between the old range and the zoomed-in range.

Specifically, we need a linear scale, with its output range set to be the actual range of the plotting area. Then set the domain according to the range of the brushed area that we want to stretch to cover the plotting area. Finally, we figure out the range of the ordinal scale by using the linear scale to figure out how far off the screen the original max and min values of the range would be. And from there, we-can resize the other ordinal scale and reposition all the rectangles.

In code:

//Initialization:
var main_xZoom = d3.scale.linear()
    .range([0, main_width - 275])
    .domain([0, main_width - 275]);

//Brushing function:
function brushed() {
    var originalRange = main_xZoom.range();
    main_xZoom.domain(brush.empty() ? 
                     originalRange: 
                     brush.extent() );

    main_x0.rangeRoundBands( [
        main_xZoom(originalRange[0]),
        main_xZoom(originalRange[1])
        ], 0.2);

    main_x1.rangeRoundBands([0, main_x0.rangeBand()], 0);

    bar.selectAll("rect")
        .attr("transform", function (d) {
            return "translate(" + main_x0(d.date) + ",0)";
        })
        .attr("width", function (d) {
            return main_x1.rangeBand();
        })
        .attr("x", function (d) {
            return main_x1(d.portfolio);
        });

    main.select("g.x.axis").call(main_xAxis);
}

Working fiddle based on your simplified code (Note: you still need to set a clipping rectangle on the main plot):
http://fiddle.jshell.net/CjaD3/1/

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top