Skip to content

Commit 49106aa

Browse files
authored
Merge pull request #1189 from plotly/cdf
Add 'cumulative' histogram 'mode' for CDF
2 parents 0435c78 + 53b61aa commit 49106aa

File tree

10 files changed

+311
-20
lines changed

10 files changed

+311
-20
lines changed

src/plot_api/plot_api.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -1296,7 +1296,8 @@ function _restyle(gd, aobj, _traces) {
12961296
'tilt', 'tiltaxis', 'depth', 'direction', 'rotation', 'pull',
12971297
'line.showscale', 'line.cauto', 'line.autocolorscale', 'line.reversescale',
12981298
'marker.line.showscale', 'marker.line.cauto', 'marker.line.autocolorscale', 'marker.line.reversescale',
1299-
'xcalendar', 'ycalendar'
1299+
'xcalendar', 'ycalendar',
1300+
'cumulative', 'cumulative.enabled', 'cumulative.direction', 'cumulative.currentbin'
13001301
];
13011302

13021303
for(i = 0; i < traces.length; i++) {

src/plots/cartesian/axes.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,10 @@ function autoShiftNumericBins(binStart, data, ax, dataMin, dataMax) {
609609
// otherwise start half an integer down regardless of
610610
// the bin size, just enough to clear up endpoint
611611
// ambiguity about which integers are in which bins.
612-
else binStart -= 0.5;
612+
else {
613+
binStart -= 0.5;
614+
if(binStart + ax.dtick < dataMin) binStart += ax.dtick;
615+
}
613616
}
614617
else if(midcount < dataCount * 0.1) {
615618
if(edgecount > dataCount * 0.3 ||

src/traces/histogram/attributes.js

+54-6
Original file line numberDiff line numberDiff line change
@@ -56,21 +56,69 @@ module.exports = {
5656
'If **, the span of each bar corresponds to the number of',
5757
'occurrences (i.e. the number of data points lying inside the bins).',
5858

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

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

68-
'If *probability density*, the span of each bar corresponds to the',
69+
'If *probability density*, the area of each bar corresponds to the',
6970
'probability that an event will fall into the corresponding bin',
70-
'(here, the sum of all bin area equals 1).'
71+
'(here, the sum of all bin AREAS equals 1).'
7172
].join(' ')
7273
},
7374

75+
cumulative: {
76+
enabled: {
77+
valType: 'boolean',
78+
dflt: false,
79+
role: 'info',
80+
description: [
81+
'If true, display the cumulative distribution by summing the',
82+
'binned values. Use the `direction` and `centralbin` attributes',
83+
'to tune the accumulation method.',
84+
'Note: in this mode, the *density* `histnorm` settings behave',
85+
'the same as their equivalents without *density*:',
86+
'** and *density* both rise to the number of data points, and',
87+
'*probability* and *probability density* both rise to the',
88+
'number of sample points.'
89+
].join(' ')
90+
},
91+
92+
direction: {
93+
valType: 'enumerated',
94+
values: ['increasing', 'decreasing'],
95+
dflt: 'increasing',
96+
role: 'info',
97+
description: [
98+
'Only applies if cumulative is enabled.',
99+
'If *increasing* (default) we sum all prior bins, so the result',
100+
'increases from left to right. If *decreasing* we sum later bins',
101+
'so the result decreases from left to right.'
102+
].join(' ')
103+
},
104+
105+
currentbin: {
106+
valType: 'enumerated',
107+
values: ['include', 'exclude', 'half'],
108+
dflt: 'include',
109+
role: 'info',
110+
description: [
111+
'Only applies if cumulative is enabled.',
112+
'Sets whether the current bin is included, excluded, or has half',
113+
'of its value included in the current cumulative value.',
114+
'*include* is the default for compatibility with various other',
115+
'tools, however it introduces a half-bin bias to the results.',
116+
'*exclude* makes the opposite half-bin bias, and *half* removes',
117+
'it.'
118+
].join(' ')
119+
}
120+
},
121+
74122
autobinx: {
75123
valType: 'boolean',
76124
dflt: null,

src/traces/histogram/bin_functions.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@ module.exports = {
4747
return v;
4848
}
4949
else if(size[n] > v) {
50+
var delta = v - size[n];
5051
size[n] = v;
51-
return v - size[n];
52+
return delta;
5253
}
5354
}
5455
return 0;
@@ -63,8 +64,9 @@ module.exports = {
6364
return v;
6465
}
6566
else if(size[n] < v) {
67+
var delta = v - size[n];
6668
size[n] = v;
67-
return v - size[n];
69+
return delta;
6870
}
6971
}
7072
return 0;

src/traces/histogram/calc.js

+91-9
Original file line numberDiff line numberDiff line change
@@ -33,22 +33,38 @@ module.exports = function calc(gd, trace) {
3333
trace.orientation === 'h' ? (trace.yaxis || 'y') : (trace.xaxis || 'x')),
3434
maindata = trace.orientation === 'h' ? 'y' : 'x',
3535
counterdata = {x: 'y', y: 'x'}[maindata],
36-
calendar = trace[maindata + 'calendar'];
36+
calendar = trace[maindata + 'calendar'],
37+
cumulativeSpec = trace.cumulative;
3738

3839
cleanBins(trace, pa, maindata);
3940

4041
// prepare the raw data
4142
var pos0 = pa.makeCalcdata(trace, maindata);
43+
4244
// calculate the bins
43-
if((trace['autobin' + maindata] !== false) || !(maindata + 'bins' in trace)) {
44-
trace[maindata + 'bins'] = Axes.autoBin(pos0, pa, trace['nbins' + maindata], false, calendar);
45+
var binAttr = maindata + 'bins',
46+
binspec;
47+
if((trace['autobin' + maindata] !== false) || !(binAttr in trace)) {
48+
binspec = Axes.autoBin(pos0, pa, trace['nbins' + maindata], false, calendar);
49+
50+
// adjust for CDF edge cases
51+
if(cumulativeSpec.enabled && (cumulativeSpec.currentbin !== 'include')) {
52+
if(cumulativeSpec.direction === 'decreasing') {
53+
binspec.start = pa.c2r(pa.r2c(binspec.start) - binspec.size);
54+
}
55+
else {
56+
binspec.end = pa.c2r(pa.r2c(binspec.end) + binspec.size);
57+
}
58+
}
4559

46-
// copy bin info back to the source data.
47-
trace._input[maindata + 'bins'] = trace[maindata + 'bins'];
60+
// copy bin info back to the source and full data.
61+
trace._input[binAttr] = trace[binAttr] = binspec;
62+
}
63+
else {
64+
binspec = trace[binAttr];
4865
}
4966

50-
var binspec = trace[maindata + 'bins'],
51-
nonuniformBins = typeof binspec.size === 'string',
67+
var nonuniformBins = typeof binspec.size === 'string',
5268
bins = nonuniformBins ? [] : binspec,
5369
// make the empty bin array
5470
i2,
@@ -59,8 +75,16 @@ module.exports = function calc(gd, trace) {
5975
total = 0,
6076
norm = trace.histnorm,
6177
func = trace.histfunc,
62-
densitynorm = norm.indexOf('density') !== -1,
63-
extremefunc = func === 'max' || func === 'min',
78+
densitynorm = norm.indexOf('density') !== -1;
79+
80+
if(cumulativeSpec.enabled && densitynorm) {
81+
// we treat "cumulative" like it means "integral" if you use a density norm,
82+
// which in the end means it's the same as without "density"
83+
norm = norm.replace(/ ?density$/, '');
84+
densitynorm = false;
85+
}
86+
87+
var extremefunc = func === 'max' || func === 'min',
6488
sizeinit = extremefunc ? null : 0,
6589
binfunc = binFunctions.count,
6690
normfunc = normFunctions[norm],
@@ -115,6 +139,10 @@ module.exports = function calc(gd, trace) {
115139
if(doavg) total = doAvg(size, counts);
116140
if(normfunc) normfunc(size, total, inc);
117141

142+
// after all normalization etc, now we can accumulate if desired
143+
if(cumulativeSpec.enabled) cdf(size, cumulativeSpec.direction, cumulativeSpec.currentbin);
144+
145+
118146
var serieslen = Math.min(pos.length, size.length),
119147
cd = [],
120148
firstNonzero = 0,
@@ -142,3 +170,57 @@ module.exports = function calc(gd, trace) {
142170

143171
return cd;
144172
};
173+
174+
function cdf(size, direction, currentbin) {
175+
var i,
176+
vi,
177+
prevSum;
178+
179+
function firstHalfPoint(i) {
180+
prevSum = size[i];
181+
size[i] /= 2;
182+
}
183+
184+
function nextHalfPoint(i) {
185+
vi = size[i];
186+
size[i] = prevSum + vi / 2;
187+
prevSum += vi;
188+
}
189+
190+
if(currentbin === 'half') {
191+
192+
if(direction === 'increasing') {
193+
firstHalfPoint(0);
194+
for(i = 1; i < size.length; i++) {
195+
nextHalfPoint(i);
196+
}
197+
}
198+
else {
199+
firstHalfPoint(size.length - 1);
200+
for(i = size.length - 2; i >= 0; i--) {
201+
nextHalfPoint(i);
202+
}
203+
}
204+
}
205+
else if(direction === 'increasing') {
206+
for(i = 1; i < size.length; i++) {
207+
size[i] += size[i - 1];
208+
}
209+
210+
// 'exclude' is identical to 'include' just shifted one bin over
211+
if(currentbin === 'exclude') {
212+
size.unshift(0);
213+
size.pop();
214+
}
215+
}
216+
else {
217+
for(i = size.length - 2; i >= 0; i--) {
218+
size[i] += size[i + 1];
219+
}
220+
221+
if(currentbin === 'exclude') {
222+
size.push(0);
223+
size.shift();
224+
}
225+
}
226+
}

src/traces/histogram/defaults.js

+6
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
2727
var x = coerce('x'),
2828
y = coerce('y');
2929

30+
var cumulative = coerce('cumulative.enabled');
31+
if(cumulative) {
32+
coerce('cumulative.direction');
33+
coerce('cumulative.currentbin');
34+
}
35+
3036
coerce('text');
3137

3238
var orientation = coerce('orientation', (y && !x) ? 'h' : 'v'),
6.62 KB
Loading
+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"data": [{
3+
"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],
4+
"type": "histogram",
5+
"cumulative": {"enabled": true},
6+
"xbins": {"start": 0.5, "end": 10.5, "size": 1},
7+
"marker": {"color": "blue", "line": {"width": 2, "color": "#000"}},
8+
"name": "A"
9+
},
10+
{
11+
"x": [3, 3, 4, 5, 6, 7, 7],
12+
"type": "histogram",
13+
"cumulative": {"enabled": true, "currentbin": "exclude"},
14+
"xbins": {"start": 0.5, "end": 10.5, "size": 1},
15+
"marker": {"color": "red", "line": {"width": 2, "color": "#000"}},
16+
"name": "B"
17+
},
18+
{
19+
"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],
20+
"type": "box",
21+
"orientation": "h",
22+
"yaxis": "y2",
23+
"line": {"color": "blue"},
24+
"showlegend": false
25+
},
26+
{
27+
"x": [3, 3, 4, 5, 6, 7, 7],
28+
"type": "box",
29+
"orientation": "h",
30+
"yaxis": "y2",
31+
"line": {"color": "red"},
32+
"showlegend": false
33+
}],
34+
"layout": {
35+
"yaxis": {"domain": [0, 0.8]},
36+
"yaxis2": {"domain": [0.8, 1], "showline": false, "showticklabels": false},
37+
"height": 300,
38+
"width": 400,
39+
"barmode": "stack"
40+
}
41+
}

test/jasmine/tests/axes_test.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -1809,7 +1809,7 @@ describe('Test axes', function() {
18091809
);
18101810

18111811
expect(out).toEqual({
1812-
start: -0.5,
1812+
start: 0.5,
18131813
end: 4.5,
18141814
size: 1
18151815
});
@@ -1822,6 +1822,9 @@ describe('Test axes', function() {
18221822
2
18231823
);
18241824

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

0 commit comments

Comments
 (0)