Skip to content

Commit

Permalink
Merge pull request #1189 from plotly/cdf
Browse files Browse the repository at this point in the history
Add 'cumulative' histogram 'mode' for CDF
  • Loading branch information
alexcjohnson committed Jan 18, 2017
2 parents 0435c78 + 53b61aa commit 49106aa
Show file tree
Hide file tree
Showing 10 changed files with 311 additions and 20 deletions.
3 changes: 2 additions & 1 deletion src/plot_api/plot_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -1296,7 +1296,8 @@ function _restyle(gd, aobj, _traces) {
'tilt', 'tiltaxis', 'depth', 'direction', 'rotation', 'pull',
'line.showscale', 'line.cauto', 'line.autocolorscale', 'line.reversescale',
'marker.line.showscale', 'marker.line.cauto', 'marker.line.autocolorscale', 'marker.line.reversescale',
'xcalendar', 'ycalendar'
'xcalendar', 'ycalendar',
'cumulative', 'cumulative.enabled', 'cumulative.direction', 'cumulative.currentbin'
];

for(i = 0; i < traces.length; i++) {
Expand Down
5 changes: 4 additions & 1 deletion src/plots/cartesian/axes.js
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,10 @@ function autoShiftNumericBins(binStart, data, ax, dataMin, dataMax) {
// otherwise start half an integer down regardless of
// the bin size, just enough to clear up endpoint
// ambiguity about which integers are in which bins.
else binStart -= 0.5;
else {
binStart -= 0.5;
if(binStart + ax.dtick < dataMin) binStart += ax.dtick;
}
}
else if(midcount < dataCount * 0.1) {
if(edgecount > dataCount * 0.3 ||
Expand Down
60 changes: 54 additions & 6 deletions src/traces/histogram/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,21 +56,69 @@ module.exports = {
'If **, the span of each bar corresponds to the number of',
'occurrences (i.e. the number of data points lying inside the bins).',

'If *percent*, the span of each bar corresponds to the percentage',
'of occurrences with respect to the total number of sample points',
'(here, the sum of all bin area equals 100%).',
'If *percent* / *probability*, the span of each bar corresponds to',
'the percentage / fraction of occurrences with respect to the total',
'number of sample points',
'(here, the sum of all bin HEIGHTS equals 100% / 1).',

'If *density*, the span of each bar corresponds to the number of',
'occurrences in a bin divided by the size of the bin interval',
'(here, the sum of all bin area equals the',
'(here, the sum of all bin AREAS equals the',
'total number of sample points).',

'If *probability density*, the span of each bar corresponds to the',
'If *probability density*, the area of each bar corresponds to the',
'probability that an event will fall into the corresponding bin',
'(here, the sum of all bin area equals 1).'
'(here, the sum of all bin AREAS equals 1).'
].join(' ')
},

cumulative: {
enabled: {
valType: 'boolean',
dflt: false,
role: 'info',
description: [
'If true, display the cumulative distribution by summing the',
'binned values. Use the `direction` and `centralbin` attributes',
'to tune the accumulation method.',
'Note: in this mode, the *density* `histnorm` settings behave',
'the same as their equivalents without *density*:',
'** and *density* both rise to the number of data points, and',
'*probability* and *probability density* both rise to the',
'number of sample points.'
].join(' ')
},

direction: {
valType: 'enumerated',
values: ['increasing', 'decreasing'],
dflt: 'increasing',
role: 'info',
description: [
'Only applies if cumulative is enabled.',
'If *increasing* (default) we sum all prior bins, so the result',
'increases from left to right. If *decreasing* we sum later bins',
'so the result decreases from left to right.'
].join(' ')
},

currentbin: {
valType: 'enumerated',
values: ['include', 'exclude', 'half'],
dflt: 'include',
role: 'info',
description: [
'Only applies if cumulative is enabled.',
'Sets whether the current bin is included, excluded, or has half',
'of its value included in the current cumulative value.',
'*include* is the default for compatibility with various other',
'tools, however it introduces a half-bin bias to the results.',
'*exclude* makes the opposite half-bin bias, and *half* removes',
'it.'
].join(' ')
}
},

autobinx: {
valType: 'boolean',
dflt: null,
Expand Down
6 changes: 4 additions & 2 deletions src/traces/histogram/bin_functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ module.exports = {
return v;
}
else if(size[n] > v) {
var delta = v - size[n];
size[n] = v;
return v - size[n];
return delta;
}
}
return 0;
Expand All @@ -63,8 +64,9 @@ module.exports = {
return v;
}
else if(size[n] < v) {
var delta = v - size[n];
size[n] = v;
return v - size[n];
return delta;
}
}
return 0;
Expand Down
100 changes: 91 additions & 9 deletions src/traces/histogram/calc.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,38 @@ module.exports = function calc(gd, trace) {
trace.orientation === 'h' ? (trace.yaxis || 'y') : (trace.xaxis || 'x')),
maindata = trace.orientation === 'h' ? 'y' : 'x',
counterdata = {x: 'y', y: 'x'}[maindata],
calendar = trace[maindata + 'calendar'];
calendar = trace[maindata + 'calendar'],
cumulativeSpec = trace.cumulative;

cleanBins(trace, pa, maindata);

// prepare the raw data
var pos0 = pa.makeCalcdata(trace, maindata);

// calculate the bins
if((trace['autobin' + maindata] !== false) || !(maindata + 'bins' in trace)) {
trace[maindata + 'bins'] = Axes.autoBin(pos0, pa, trace['nbins' + maindata], false, calendar);
var binAttr = maindata + 'bins',
binspec;
if((trace['autobin' + maindata] !== false) || !(binAttr in trace)) {
binspec = Axes.autoBin(pos0, pa, trace['nbins' + maindata], false, calendar);

// adjust for CDF edge cases
if(cumulativeSpec.enabled && (cumulativeSpec.currentbin !== 'include')) {
if(cumulativeSpec.direction === 'decreasing') {
binspec.start = pa.c2r(pa.r2c(binspec.start) - binspec.size);
}
else {
binspec.end = pa.c2r(pa.r2c(binspec.end) + binspec.size);
}
}

// copy bin info back to the source data.
trace._input[maindata + 'bins'] = trace[maindata + 'bins'];
// copy bin info back to the source and full data.
trace._input[binAttr] = trace[binAttr] = binspec;
}
else {
binspec = trace[binAttr];
}

var binspec = trace[maindata + 'bins'],
nonuniformBins = typeof binspec.size === 'string',
var nonuniformBins = typeof binspec.size === 'string',
bins = nonuniformBins ? [] : binspec,
// make the empty bin array
i2,
Expand All @@ -59,8 +75,16 @@ module.exports = function calc(gd, trace) {
total = 0,
norm = trace.histnorm,
func = trace.histfunc,
densitynorm = norm.indexOf('density') !== -1,
extremefunc = func === 'max' || func === 'min',
densitynorm = norm.indexOf('density') !== -1;

if(cumulativeSpec.enabled && densitynorm) {
// we treat "cumulative" like it means "integral" if you use a density norm,
// which in the end means it's the same as without "density"
norm = norm.replace(/ ?density$/, '');
densitynorm = false;
}

var extremefunc = func === 'max' || func === 'min',
sizeinit = extremefunc ? null : 0,
binfunc = binFunctions.count,
normfunc = normFunctions[norm],
Expand Down Expand Up @@ -115,6 +139,10 @@ module.exports = function calc(gd, trace) {
if(doavg) total = doAvg(size, counts);
if(normfunc) normfunc(size, total, inc);

// after all normalization etc, now we can accumulate if desired
if(cumulativeSpec.enabled) cdf(size, cumulativeSpec.direction, cumulativeSpec.currentbin);


var serieslen = Math.min(pos.length, size.length),
cd = [],
firstNonzero = 0,
Expand Down Expand Up @@ -142,3 +170,57 @@ module.exports = function calc(gd, trace) {

return cd;
};

function cdf(size, direction, currentbin) {
var i,
vi,
prevSum;

function firstHalfPoint(i) {
prevSum = size[i];
size[i] /= 2;
}

function nextHalfPoint(i) {
vi = size[i];
size[i] = prevSum + vi / 2;
prevSum += vi;
}

if(currentbin === 'half') {

if(direction === 'increasing') {
firstHalfPoint(0);
for(i = 1; i < size.length; i++) {
nextHalfPoint(i);
}
}
else {
firstHalfPoint(size.length - 1);
for(i = size.length - 2; i >= 0; i--) {
nextHalfPoint(i);
}
}
}
else if(direction === 'increasing') {
for(i = 1; i < size.length; i++) {
size[i] += size[i - 1];
}

// 'exclude' is identical to 'include' just shifted one bin over
if(currentbin === 'exclude') {
size.unshift(0);
size.pop();
}
}
else {
for(i = size.length - 2; i >= 0; i--) {
size[i] += size[i + 1];
}

if(currentbin === 'exclude') {
size.push(0);
size.shift();
}
}
}
6 changes: 6 additions & 0 deletions src/traces/histogram/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
var x = coerce('x'),
y = coerce('y');

var cumulative = coerce('cumulative.enabled');
if(cumulative) {
coerce('cumulative.direction');
coerce('cumulative.currentbin');
}

coerce('text');

var orientation = coerce('orientation', (y && !x) ? 'h' : 'v'),
Expand Down
Binary file added test/image/baselines/hist_cum_stacked.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 41 additions & 0 deletions test/image/mocks/hist_cum_stacked.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"data": [{
"x": [1, 2, 2, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 5, 6, 6, 6, 6, 7, 7, 7, 8, 8, 9, 9, 10],
"type": "histogram",
"cumulative": {"enabled": true},
"xbins": {"start": 0.5, "end": 10.5, "size": 1},
"marker": {"color": "blue", "line": {"width": 2, "color": "#000"}},
"name": "A"
},
{
"x": [3, 3, 4, 5, 6, 7, 7],
"type": "histogram",
"cumulative": {"enabled": true, "currentbin": "exclude"},
"xbins": {"start": 0.5, "end": 10.5, "size": 1},
"marker": {"color": "red", "line": {"width": 2, "color": "#000"}},
"name": "B"
},
{
"x": [1, 2, 2, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 5, 6, 6, 6, 6, 7, 7, 7, 8, 8, 9, 9, 10],
"type": "box",
"orientation": "h",
"yaxis": "y2",
"line": {"color": "blue"},
"showlegend": false
},
{
"x": [3, 3, 4, 5, 6, 7, 7],
"type": "box",
"orientation": "h",
"yaxis": "y2",
"line": {"color": "red"},
"showlegend": false
}],
"layout": {
"yaxis": {"domain": [0, 0.8]},
"yaxis2": {"domain": [0.8, 1], "showline": false, "showticklabels": false},
"height": 300,
"width": 400,
"barmode": "stack"
}
}
5 changes: 4 additions & 1 deletion test/jasmine/tests/axes_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1809,7 +1809,7 @@ describe('Test axes', function() {
);

expect(out).toEqual({
start: -0.5,
start: 0.5,
end: 4.5,
size: 1
});
Expand All @@ -1822,6 +1822,9 @@ describe('Test axes', function() {
2
);

// when size > 1 with all integers, we want the starting point to be
// a half integer below the round number a tick would be at (in this case 0)
// to approximate the half-open interval [) that's commonly used.
expect(out).toEqual({
start: -0.5,
end: 5.5,
Expand Down

0 comments on commit 49106aa

Please sign in to comment.