dc.js icon indicating copy to clipboard operation
dc.js copied to clipboard

Stack mixin charts' y-domain is always [0, n] when elasticY(true)

Open mtraynham opened this issue 10 years ago • 11 comments

Problem

The series chart (which is a composite mixin) draws all stack mixin children (typically line charts) incorrectly when elasticY is set to true. I would expect the highest value to be at the top of the chart and the lowest value to be at the bottom, flush with the axis.

Here is a screen of the issue: screenshot from 2014-08-07 15 39 38

Because the series chart (composite mixin) will add multiple child charts, the stack mixin is effectively only rendering n number of single group stacks (where the data point has value {y: value, y0: 0}. The issue arises when accumulating the y-axis min, in the child's yAxisMin function of stack-mixin.js:

    _chart.yAxisMin = function () {
        var min = d3.min(flattenStack(), function (p) {
            // Because y is typically greater than y0
            // (which in our case is 0), this returns 0.
            return (p.y + p.y0 < p.y0) ? (p.y + p.y0) : p.y0;
        });
        return dc.utils.subtract(min, _chart.yAxisPadding());
    };
Workaround

A workaround is changing the series-chart's _chartFunction to properly return the min value for the group, using the chart method:

var chart = new dc.seriesChart("#chart1", "group1")
      .chart(function (c) {
          var child = dc.lineChart(c);
          dc.override(child, 'yAxisMin', function () {
              var min = d3.min(child.data(), function (layer) {
                  return d3.min(layer.values, function (p) {
                      return p.y + p.y0;
                  });
              });
              return dc.utils.subtract(min, child.yAxisPadding());
          });
          return child;
      });

screenshot from 2014-08-07 15 38 53

Thoughts

The more I think about it, the less I like the fact that series is a chart type... It really only applies to line charts. It would be better if the stackMixin handled these cases, such as:

  • a seriesAccessor function
  • lines (stacked, series) & bars (stacked, grouped)
    • the animation between these seems to be a big missing feature

The list of bugs around stack & series has been growing for a while, so they definitely deserve some attention and it would be nice to provide an all in one true stack/series/group mixin.

mtraynham avatar Aug 07 '14 20:08 mtraynham

It definitely should be series or stacks (or something else) but not both. I don't think either is very well thought out, but stacks are debugged better.

Note that bar series are sort of a better starting point for grouped bars than stacks (maybe), and that scatter series do exist, although you could argue that it's just coloring based on another variable.

Ironically, there is something conceptually missing about multidimensional data in general. This is all kind of road-to-3.0 stuff, but just defining what multidimensional input looks like, in a consistent way (I mean, with accessors rather than just array-of-array gunk), would help a lot.

gordonwoodhull avatar Aug 07 '14 22:08 gordonwoodhull

I may be wrong, but it seems to me the elasticY property in the above example is consistent with how elasticY (or elasticX in a rowchart) works elsewhere in dc.js, e.g. elasticY in a bar chart draws the scale [0,n] where n is the maximum and changes depending on filtering, same for elasticX in a row chart. Perhaps the issue could be phrased instead as a request to have a new automatic scaling option for min & max combined. Of course it is possible to do this manually by setting the domain to the group extent, and I use this frequently in my charts - I also have some charts where I have a bootstrap glyphicon (zoom in/out) and associated jQuery routine to enable the user to flip between a zoomed-in (extent) & zoomed-out (0,n) scale, because both views have value. One point to note when employing this technique on bar charts & row charts is to make the scale minimum less than the data minimum to allow the rows/bars to be selectable for filtering.

lbk3918 avatar Dec 22 '14 16:12 lbk3918

Interesting point @lbk3918. When I created the issue, I was under the impression elasticY was synonymous with elasticX, or acted in a matter that was scale to fit. For example, a bar chart with elasticX === true and an xScale of d3.scale.time, will take the lowest date to the highest date. It only seemed appropriate to me that the yScale act in the same manner.

Although, [0, n] doesn't always look bad, the problem can be exacerbated when the yScale has low cardinality but really high values (such as my first chart). The lines become flat and blend together.

Off topic, that zoom switching sounds pretty cool.

mtraynham avatar Dec 22 '14 17:12 mtraynham

Yes I can see how elasticX & elasticY seem to work differently - I work mostly with ordinal x-axes (although these are often Month Names or Week Numbers) so I hadn't got that same perspective. I prefer using ordinals because;

  • clicking an ordinal bar to filter on it is a bit more intuitive than using the brush
  • brushOn(false) enables tooltip-style .title, which can convey lots of useful information - can't useem to get these if the brush is enabled.

The Zoom scale switching was fun to implement, a classic case where a customer wants to focus on the differences but the account manager wants to put the dramatic peaks & troughs into perspective as being between 99-100% & unnoticeable at full scale - so the chart draws (0,100%) initially but can be zoomed in (min, 100%) or out by clicking on an icon by the chart heading. The hardest part was finding the exact correct syntax to set the scale based on extent of the group - most of the dc.js examples seem to use fixed scales rather than data-driven.

lbk3918 avatar Dec 22 '14 17:12 lbk3918

I'm not sure why people associate this problem with the composite charts rather than all line i.e. stack mixin charts. Retitling.

It's the classic debate of "should my chart start at zero?" infamous from the classic "how to lie with statistics" book, and I don't think there is one answer that fits for all purposes.

gordonwoodhull avatar Jul 13 '16 16:07 gordonwoodhull

To generalize the workaround, create a function:

function nonzero_min(chart) {
    dc.override(chart, 'yAxisMin', function () {
         var min = d3.min(chart.data(), function (layer) {
             return d3.min(layer.values, function (p) {
                 return p.y + p.y0;
             });
         });
         return dc.utils.subtract(min, chart.yAxisPadding());
    });
    return chart;
}

Apply this function to a single chart; add it to the chart() generator for a series chart as @mtraynham shows above; or wrap each chart as you're passing them to a composite chart.

gordonwoodhull avatar Jul 13 '16 16:07 gordonwoodhull

Earlier discussion, with a preRender/preRedraw - based workaround: #216.

gordonwoodhull avatar Jul 13 '16 16:07 gordonwoodhull

Thanks Gordon, but I can't find how to apply it to my example:

http://jsfiddle.net/u57bfje8/31/

leo-combes avatar Jul 13 '16 17:07 leo-combes

In your example:

    .compose([
        nonzero_min(dc.lineChart(chart2)
            .dimension(timeDimension)
            .colors('red')
            .group(enabledA, "enabledA")
            // .dashStyle([2,2])
            .interpolate('step-after')
            .renderArea(false)
            .brushOn(false)
            .renderDataPoints(true)
            .clipPadding(10)),
        nonzero_min(dc.lineChart(chart2)
            .dimension(timeDimension)
            // .colors('red')
            .group(enabledC, "enabledC")
            // .dashStyle([2,2])
            .interpolate('step-after')
            .renderArea(false)
            .brushOn(false)
            .renderDataPoints(true)
            .clipPadding(10)),                        
        nonzero_min(dc.lineChart(chart2)
            .dimension(timeDimension)
            .colors('orange')
            .group(enabledB, "enabledB")
            // .dashStyle([5,5])
            .interpolate('step-after')
            .renderArea(false)
            .brushOn(false)
            .renderDataPoints(true)
            .clipPadding(10))     

fork of your fiddle: http://jsfiddle.net/gordonwoodhull/7anae5c5/1/

gordonwoodhull avatar Jul 13 '16 17:07 gordonwoodhull

To generalize the workaround, create a function:

function nonzero_min(chart) {
    dc.override(chart, 'yAxisMin', function () {
         var min = d3.min(chart.data(), function (layer) {
             return d3.min(layer.values, function (p) {
                 return p.y + p.y0;
             });
         });
         return dc.utils.subtract(min, chart.yAxisPadding());
    });
    return chart;
}

Apply this function to a single chart; add it to the chart() generator for a series chart as @mtraynham shows above; or wrap each chart as you're passing them to a composite chart.

How would you modify this so y axis minimum is not exactly minimum of data, but slightly lower so that below 5% of y axis bottom is empty (no y data points), also similarly I want to get a buffer on y axis top so largest points dont touch the top of the y axis

erdult avatar Jun 12 '21 13:06 erdult

Hi @erdult. Did you try setting the yAxisPadding which both this and yAxisMax use? It allows percentage units.

gordonwoodhull avatar Jun 12 '21 15:06 gordonwoodhull