2
votes

Vistas has a good example on github with a setup on how to make a waterfall in dc.js. It uses a second dataset to actually create the bottom of the stacked bar chart. However, if you filter in the first dataset it will work incorrectly since the bottom value of the stacked chart are fixed.

My question is therefore is it possible to calculate the d.value based on this formula, so no second dataset (dummy_data) is needed:

Dummy value of current column = previous dummy value + previous real data value

whereby the value of the first and last column is set to 0

JSFiddle

Code

<!DOCTYPE html>
<html lang='en'>
<head>
  <meta charset='utf-8'>
  <meta content='width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0' name='viewport'>

  <title>Waterfall Chart with DC.js</title>

  <script src='js/d3.js' type='text/javascript'></script>
  <script src='js/crossfilter.js' type='text/javascript'></script>
  <script src='js/reductio.js' type='text/javascript'></script>
  <script src='js/dc.js' type='text/javascript'></script>
  <link href='css/dc.css' rel='stylesheet' type='text/css'>
</head>
<body>
  <div class='pie-graph span6' id='dc-waterfall-chart'></div>
<script>
  var waterfallChart = dc.barChart("#dc-waterfall-chart");
  var original_data = [];
  var dummy_data = [];

  //creating example data - could easily be any data reading process from sources like CSV or JSON
  original_data.push({item: "x0", value: 10});
  original_data.push({item: "x1", value: 2});
  original_data.push({item: "x2", value: -1});
  original_data.push({item: "x3", value: -3});
  original_data.push({item: "x4", value: 8});

  //creating the dummy data, the invisible columns supporting the waterfall chart. 
  //This is going to be the first stack whereas the real data (original_data) is the 
  //second stack
  dummy_data.push({item: "x0", value: 0});
  dummy_data.push({item: "x1", value: 10});
  dummy_data.push({item: "x2", value: 12});
  dummy_data.push({item: "x3", value: 11});
  dummy_data.push({item: "x4", value: 0});

  //creating crossfilter based off of the real data. Again, you can have your own crossfilter creation process here.
  var ndx = crossfilter(original_data);
  var itemDimension = ndx.dimension(function (d) { return d.item; });
  var reducerValue = reductio().count(true).sum(function(d) { return d.value; }).avg(true); 
  var itemGroup = itemDimension.group();
  var grp = reducerValue(itemGroup);

  // we should also have a similar cross filter on the dummy data
  var ndx_dummy = crossfilter(dummy_data);
  var itemDimension_dummy = ndx_dummy.dimension(function (d) { return d.item; });
  var reducerValue_dummy = reductio().count(true).sum(function(d) { return d.value; }).avg(true); 
  var itemGroup_dummy = itemDimension_dummy.group();
  var dummy_grp = reducerValue_dummy(itemGroup_dummy);

  waterfallChart.width(600)
  .height(400)
  .margins({top: 5, right: 40, bottom: 80, left: 40})
  .dimension(itemDimension)
  .group(dummy_grp)
  .valueAccessor(function (d) { // specific to reductio
    return d.value.sum; 
    })
  .title(function(d){ 
    return (d.key + "  (" + d.value.sum+ ")" );
  })
  .transitionDuration(1000)
  .centerBar(false) 
  .gap(7)                    
  .x(d3.scaleBand())
  .xUnits(dc.units.ordinal)
    .controlsUseVisibility(true)
    .addFilterHandler(function(filters, filter) {return [filter];})
  .elasticY(true)
  .xAxis().tickFormat(function(v) {return v;});

  waterfallChart.stack(grp,"x")

  waterfallChart.on("pretransition",function (chart) {
    //coloring the bars
    chart.selectAll("rect.bar").style("fill", function(d){return "white";});
    chart.selectAll("rect.bar").style("stroke", "#ccc");//change the color to white if you want a clean waterfall without dashed boundaries
    chart.selectAll("rect.bar").style("stroke-dasharray", "1,0,2,0,1");

    // stack._1 is your real data, whereas stack._0 is the dummy data. You want to treat the styling of these stacks differently
    chart.selectAll("svg g g.chart-body g.stack._1 rect.bar").style("fill", function(d){console.log(d.data.value.sum);if (d.data.value.sum >0) return '#ff7c19'; else return '#7c7c7c';});
    chart.selectAll("svg g g.chart-body g.stack._1 rect.bar").style("stroke", "white");
    chart.selectAll("svg g g.chart-body g.stack._1 rect.bar").style("stroke-dasharray", "1");
    // chose the color of deselected bars, but only for the real data.
    chart.selectAll("svg g g.chart-body g.stack._1 rect.deselected").style("fill", function (d) {return '#ccc';});
    chart.selectAll('g.x text').style('fill', '#8e8e8e');
    chart.selectAll('g.y text').style('fill', '#777777');
    chart.selectAll('g.x text').style('font-size', '10.5px');
    chart.selectAll('g.y.axis g.tick line').style("stroke", "#f46542");
    chart.selectAll('.domain').style("stroke","#c6c6c6");
    chart.selectAll('rect.bar').on("contextmenu",function(d){d3.event.preventDefault();});
  });
  dc.renderAll();
</script>
</body>
</html>
1
That example looks nice but it clearly only works with static data. After all these years, I still don’t understand why anyone would use dc.js for static charts with no filtering. (Or maybe the repo is a cry for help?) Looks like there should be a better solution using fake groups, hope to find time for this in the next week.Gordon
Thanks again for your help, really appreciated. Indeed i really like DC.js in the combination with crossfilter. But standalone charts without filtering maybe d3.js is better solution. Hopefully you can help me again getting this working with filtering.Kees de Jager
Hi Gordon did you already find some time tot have a look at the example? Sorry for the rush but i need tot know if this example can work. Thanks again for your help.Kees de Jager
I looked at it and I see a possible solution, since this is a form of accumulation and in some ways similar to a Pareto chart but I haven’t written any code yet. I have a lot of other responsibilities and this is just a hobby for me, but I’ll try to get to it soon.Gordon
Hi @KeesdeJager, wondering if you had a chance to look at my answer. Your bounty is about to expire. :-)Gordon

1 Answers

2
votes

We can use a fake group to accumulate values the way that is needed for the baseline and final value:

  function waterfall_group(group, endkey, acc) {
    acc = acc || (x => x);
    return {
      all: () => {
        let cumulate = 0;
        let all = group.all().map(({key,value}) => {
          value = acc(value)
            const kv = {
            key,
            value: {
                baseline: cumulate,
              data: value
            }
            };
          cumulate += value;
          return kv;
        });
        return all.concat([{key: endkey, value: {baseline: 0, data: cumulate}}]);
      }
    };
  }

This function takes the key for the final "sum total" bar and an accessor function, needed here because reductio wraps the values in an extra object.

It returns a group with values {baseline,data}, where baseline is the dummy value needed for the invisible stack, and data is the value for the bar.

Construct the fake group like

var waterfall_group = waterfall_group(grp, 'x5', x => x.sum);

and pass it to .group() and .stack() with accessors to fetch the sub-values:

waterfallChart
  .group(waterfall_group, 'baseline', kv => kv.value.baseline)
  .stack(waterfall_group, 'data', kv => kv.value.data)

I also changed the coloring code to fetch the new data format:

chart.selectAll("svg g g.chart-body g.stack._1 rect.bar")
  .style("fill", function(d){if (d.data.value.data >0) return '#ff7c19'; else return '#7c7c7c';});

To test it, I added another "category" field and a pie chart. Note that a waterfall chart can get into some weird states with negative & zero values (e.g. click "C") but they look correct.

waterfall shot

Fork of your fiddle.

Note that since the last item (x5 here) is purely synthetic and not associated with any underlying data, filtering by clicking on that item will cause other charts to blank out. I'm not sure how to disable clicks on one particular item.