Search code examples
javascriptd3.jsdc.jscrossfilterstacked-chart

dc.js Ordering X-Axis by Month


I am trying to create a Stacked Bar Chart using dc.js as following:

Data:

<pre id="data">
Status,StatusT,Phase,PhaseT,CompletionDate,CalMonth,Sales,SalesCount
3,Won,Z04,Decide,20130101,201012,2203262.13,1
3,Won,Z04,Decide,20130101,201111,607933.8,1
3,Won,Z05,Deliver,20131001,201202,66.08,1
3,Won,Z04,Decide,20120501,201202,66.08,1
3,Won,Z01,Understand,20120409,201203,65.07,1
3,Won,Z04,Decide,20121231,201204,4645214.7,1
3,Won,Z05,Deliver,20130918,201204,66.52,1
3,Won,Z04,Decide,20130418,201204,2312130.92,1
3,Won,Z05,Deliver,20120504,201205,68.07,1
3,Won,Z05,Deliver,20120504,201205,515994.86,1
3,Won,Z04,Decide,20120504,201205,68.07,1
3,Won,Z04,Decide,20120504,201205,68.07,1
</pre>

Javascript:

var dateFormat = d3.time.format('%Y%m%d');
var monthNameFormat = d3.time.format("%b-%Y");
// Prepare Data
for (var z = 0; z < opportunities.length; z++) {
  opportunities[z].DD = dateFormat.parse(opportunities[z].CompletionDate);
}

opportunities.sort(function(a,b){
  return b.DD - a.DD;
});

var ndx = crossfilter(opportunities);

var dimMonthYear = ndx.dimension(function(d) {
  //return d.CompletionDate
  return monthNameFormat(new Date(d.DD));
});

var phaseUnderstand = dimMonthYear.group().reduceSum(function(d) {
  if (d.Phase === "Z01") {
    return d.Sales;
  } else {
    return 0;
  }
});
var phasePropose = dimMonthYear.group().reduceSum(function(d) {
  if (d.Phase === "Z02") {
    return d.Sales;
  } else {
    return 0;
  }
});
var phaseNegotiate = dimMonthYear.group().reduceSum(function(d) {
  if (d.Phase === "Z03") {
    return d.Sales;
  } else {
    return 0;
  }
});
var phaseDecide = dimMonthYear.group().reduceSum(function(d) {
  if (d.Phase === "Z04") {
    return d.Sales;
  } else {
    return 0;
  }
});
var phaseDeliver = dimMonthYear.group().reduceSum(function(d) {
  if (d.Phase === "Z05") {
    return d.Sales;
  } else {
    return 0;
  }
});

chart
  .width(768)
  .height(480)
  .margins({
    left: 80,
    top: 20,
    right: 10,
    bottom: 80
  })
  .x(d3.scale.ordinal())
  .round(d3.time.month.round)
  .alwaysUseRounding(true)
  .xUnits(dc.units.ordinal)
  .brushOn(false)
  .xAxisLabel('Months')
  .yAxisLabel("Expected Sales (in thousands)")
  .dimension(dimMonthYear)
  .renderHorizontalGridLines(true)
  .renderVerticalGridLines(true)
 // .barPadding(0.1)
  //.outerPadding(0.05)
  .legend(dc.legend().x(130).y(30).itemHeight(13).gap(5))
  .group(phaseUnderstand, "Understand")
  .stack(phasePropose, "Propose")
  .stack(phaseNegotiate, "Negotiate")
  .stack(phaseDecide, "Decide")
  .stack(phaseDeliver, "Deliver")
  .centerBar(true)
  .elasticY(true)
  .elasticX(true)
  .renderlet(function(chart) {
    chart.selectAll("g.x text").attr('dx', '-30').attr(
      'dy', '-7').attr('transform', "rotate(-60)");
  });
chart.render();

I am able to render the chart but I am not able to sort the X-axis by month in ascending order and make it elastic along the x-axis. If I use just date then the chart becomes too detailed (although x-axis is still not elastic) which is not useful for the purpose I am creating it for. Here is a sample fiddle: https://jsfiddle.net/NewMonk/1hbjwxzy/67/ .


Solution

  • I strongly recommend using time scales when the X axis is time-based. It's just a little more work (and more risk of the dreaded Blank Chart), but it's way more flexible and accurate.

    First off, we need to ask crossfilter to bin by month. d3.time.month will round each date down to the nearest month:

    var dimMonthYear = ndx.dimension(function(d) {
      return d3.time.month(d.DD)
    });
    

    This has the same effect as the string-bins you were creating, but it keeps the keys as dates.

    Then we can tell dc.js to use a time-based scale and elasticX:

    chart
      .x(d3.time.scale()).elasticX(true)
      .round(d3.time.month.round)
      .alwaysUseRounding(true)
      .xUnits(d3.time.months)
    

    .xUnits is what gets the bar width right (so that dc.js can count the number of visible bars).

    We can restore the monthNameFormat tick format and show every tick like so:

    chart.xAxis()
      .tickFormat(monthNameFormat)
      .ticks(d3.time.months,1);
    

    Finally, you may need to remove empty bins if filtering with another chart, so that bars will be removed when they drop to zero:

    function remove_empty_bins(source_group) {
        return {
            all:function () {
                return source_group.all().filter(function(d) {
                    return d.value != 0;
                });
            }
        };
    }
    

    Afterword

    @Kush comments below that removing the empty bins from a stacked set of date-based groups is not easy. It is so.

    Here's a custom version of combine_groups for combining date-based groups:

    function combine_date_groups() { // (groups...)
        var groups = Array.prototype.slice.call(arguments);
        return {
            all: function() {
                var alls = groups.map(function(g) { return g.all(); });
                var gm = {};
                alls.forEach(function(a, i) {
                    a.forEach(function(b) {
                        var t = b.key.getTime();
                        if(!gm[t]) {
                            gm[t] = new Array(groups.length);
                            for(var j=0; j<groups.length; ++j)
                                gm[t][j] = 0;
                        }
                        gm[t][i] = b.value;
                    });
                });
                var ret = [];
                for(var k in gm)
                    ret.push({key: new Date(+k), value: gm[k]});
                return ret;
            }
        };
    }
    

    The nasty thing here is that we have to convert from dates to integers in order to index the objects correctly, and then convert the integers back to dates when building the fake group data.

    Apply it like this:

    var combinedGroup = combine_date_groups(remove_empty_bins(phaseUnderstand), 
        remove_empty_bins(phasePropose), 
        remove_empty_bins(phaseNegotiate), 
        remove_empty_bins(phaseDecide), 
        remove_empty_bins(phaseDeliver));
    

    Here's a helper function for pulling a stack by index:

    function sel_stack(i) {
        return function(d) {
        return d.value[i];
      };
    }
    

    Now we can specify the stacks like this:

    chart
      .group(combinedGroup, "Understand", sel_stack(0))
      .stack(combinedGroup, "Propose", sel_stack(1))
      .stack(combinedGroup, "Negotiate", sel_stack(2))
      .stack(combinedGroup, "Decide", sel_stack(3))
      .stack(combinedGroup, "Deliver", sel_stack(4))
    

    Updated fiddle here: https://jsfiddle.net/gordonwoodhull/8pc0p1we/17/

    (Forgot to post the fiddle the first time, I think it was at about version 8 before this adventure.)

    We're so far beyond what crossfilter and dc.js were originally designed for, it's kind of humorous. Data is complicated.