diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 7d18cc8fd8a..1260fbf36cc 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -569,7 +569,8 @@ axes.autoBin = function(data, ax, nbins, is2d, calendar) { return { start: dataMin - 0.5, end: dataMax + 0.5, - size: 1 + size: 1, + _count: dataMax - dataMin + 1 }; } @@ -613,8 +614,8 @@ axes.autoBin = function(data, ax, nbins, is2d, calendar) { axes.autoTicks(dummyAx, size0); var binStart = axes.tickIncrement( - axes.tickFirst(dummyAx), dummyAx.dtick, 'reverse', calendar), - binEnd; + axes.tickFirst(dummyAx), dummyAx.dtick, 'reverse', calendar); + var binEnd, bincount; // check for too many data points right at the edges of bins // (>50% within 1% of bin edges) or all data points integral @@ -622,7 +623,7 @@ axes.autoBin = function(data, ax, nbins, is2d, calendar) { if(typeof dummyAx.dtick === 'number') { binStart = autoShiftNumericBins(binStart, data, dummyAx, dataMin, dataMax); - var bincount = 1 + Math.floor((dataMax - binStart) / dummyAx.dtick); + bincount = 1 + Math.floor((dataMax - binStart) / dummyAx.dtick); binEnd = binStart + bincount * dummyAx.dtick; } else { @@ -638,15 +639,18 @@ axes.autoBin = function(data, ax, nbins, is2d, calendar) { // calculate the endpoint for nonlinear ticks - you have to // just increment until you're done binEnd = binStart; + bincount = 0; while(binEnd <= dataMax) { binEnd = axes.tickIncrement(binEnd, dummyAx.dtick, false, calendar); + bincount++; } } return { start: ax.c2r(binStart, 0, calendar), end: ax.c2r(binEnd, 0, calendar), - size: dummyAx.dtick + size: dummyAx.dtick, + _count: bincount }; }; diff --git a/src/traces/bar/sieve.js b/src/traces/bar/sieve.js index 58f802eff31..b08268eae3c 100644 --- a/src/traces/bar/sieve.js +++ b/src/traces/bar/sieve.js @@ -30,6 +30,9 @@ function Sieve(traces, separateNegativeValues, dontMergeOverlappingData) { this.separateNegativeValues = separateNegativeValues; this.dontMergeOverlappingData = dontMergeOverlappingData; + // for single-bin histograms - see histogram/calc + var width1 = Infinity; + var positions = []; for(var i = 0; i < traces.length; i++) { var trace = traces[i]; @@ -37,12 +40,16 @@ function Sieve(traces, separateNegativeValues, dontMergeOverlappingData) { var bar = trace[j]; if(bar.p !== BADNUM) positions.push(bar.p); } + if(trace[0] && trace[0].width1) { + width1 = Math.min(trace[0].width1, width1); + } } this.positions = positions; - var dv = Lib.distinctVals(this.positions); + var dv = Lib.distinctVals(positions); this.distinctPositions = dv.vals; - this.minDiff = dv.minDiff; + if(dv.vals.length === 1 && width1 !== Infinity) this.minDiff = width1; + else this.minDiff = Math.min(dv.minDiff, width1); this.binWidth = this.minDiff; diff --git a/src/traces/histogram/calc.js b/src/traces/histogram/calc.js index c293544f06a..b80c8d4f88b 100644 --- a/src/traces/histogram/calc.js +++ b/src/traces/histogram/calc.js @@ -135,7 +135,7 @@ module.exports = function calc(gd, trace) { break; } } - for(i = seriesLen - 1; i > firstNonzero; i--) { + for(i = seriesLen - 1; i >= firstNonzero; i--) { if(size[i]) { lastNonzero = i; break; @@ -149,6 +149,12 @@ module.exports = function calc(gd, trace) { } } + if(cd.length === 1) { + // when we collapse to a single bin, calcdata no longer describes bin size + // so we need to explicitly specify it + cd[0].width1 = Axes.tickIncrement(cd[0].p, binSpec.size, false, calendar) - cd[0].p; + } + arraysToCalcdata(cd, trace); return cd; @@ -161,8 +167,9 @@ module.exports = function calc(gd, trace) { * smallest bins of any of the auto values for all histograms grouped/stacked * together. */ -function calcAllAutoBins(gd, trace, pa, mainData) { +function calcAllAutoBins(gd, trace, pa, mainData, _overlayEdgeCase) { var binAttr = mainData + 'bins'; + var isOverlay = gd._fullLayout.barmode === 'overlay'; var i, tracei, calendar, firstManual, pos0; // all but the first trace in this group has already been marked finished @@ -172,7 +179,9 @@ function calcAllAutoBins(gd, trace, pa, mainData) { } else { // must be the first trace in the group - do the autobinning on them all - var traceGroup = getConnectedHistograms(gd, trace); + + // find all grouped traces - in overlay mode each trace is independent + var traceGroup = isOverlay ? [trace] : getConnectedHistograms(gd, trace); var autoBinnedTraces = []; var minSize = Infinity; @@ -196,6 +205,17 @@ function calcAllAutoBins(gd, trace, pa, mainData) { binSpec = Axes.autoBin(pos0, pa, tracei['nbins' + mainData], false, calendar); + // Edge case: single-valued histogram overlaying others + // Use them all together to calculate the bin size for the single-valued one + if(isOverlay && binSpec._count === 1 && pa.type !== 'category') { + // Several single-valued histograms! Stop infinite recursion, + // just return an extra flag that tells handleSingleValueOverlays + // to sort out this trace too + if(_overlayEdgeCase) return [binSpec, pos0, true]; + + binSpec = handleSingleValueOverlays(gd, trace, pa, mainData, binAttr); + } + // adjust for CDF edge cases if(cumulativeSpec.enabled && (cumulativeSpec.currentbin !== 'include')) { if(cumulativeSpec.direction === 'decreasing') { @@ -212,9 +232,9 @@ function calcAllAutoBins(gd, trace, pa, mainData) { } else if(!firstManual) { // Remember the first manually set binSpec. We'll try to be extra - // accommodating of this one, so other bins line up with these - // if there's more than one manual bin set and they're mutually inconsistent, - // then there's not much we can do... + // accommodating of this one, so other bins line up with these. + // But if there's more than one manual bin set and they're mutually + // inconsistent, then there's not much we can do... firstManual = { size: binSpec.size, start: pa.r2c(binSpec.start, 0, calendar), @@ -276,14 +296,90 @@ function calcAllAutoBins(gd, trace, pa, mainData) { } /* - * Return an array of traces that are all stacked or grouped together - * Only considers histograms. In principle we could include them in a + * Adjust single-value histograms in overlay mode to make as good a + * guess as we can at autobin values the user would like. + * + * Returns the binSpec for the trace that sparked all this + */ +function handleSingleValueOverlays(gd, trace, pa, mainData, binAttr) { + var overlaidTraceGroup = getConnectedHistograms(gd, trace); + var pastThisTrace = false; + var minSize = Infinity; + var singleValuedTraces = [trace]; + var i, tracei; + + // first collect all the: + // - min bin size from all multi-valued traces + // - single-valued traces + for(i = 0; i < overlaidTraceGroup.length; i++) { + tracei = overlaidTraceGroup[i]; + if(tracei === trace) pastThisTrace = true; + else if(!pastThisTrace) { + // This trace has already had its autobins calculated + // (so must not have been single-valued). + minSize = Math.min(minSize, tracei[binAttr].size); + } + else { + var resulti = calcAllAutoBins(gd, tracei, pa, mainData, true); + var binSpeci = resulti[0]; + var isSingleValued = resulti[2]; + + // so we can use this result when we get to tracei in the normal + // course of events, mark it as done and put _pos0 back + tracei._autoBinFinished = 1; + tracei._pos0 = resulti[1]; + + if(isSingleValued) { + singleValuedTraces.push(tracei); + } + else { + minSize = Math.min(minSize, binSpeci.size); + } + } + } + + // find the real data values for each single-valued trace + // hunt through pos0 for the first valid value + var dataVals = new Array(singleValuedTraces.length); + for(i = 0; i < singleValuedTraces.length; i++) { + var pos0 = singleValuedTraces[i]._pos0; + for(var j = 0; j < pos0.length; j++) { + if(pos0[j] !== undefined) { + dataVals[i] = pos0[j]; + break; + } + } + } + + // are ALL traces are single-valued? use the min difference between + // all of their values (which defaults to 1 if there's still only one) + if(!isFinite(minSize)) { + minSize = Lib.distinctVals(dataVals).minDiff; + } + + // now apply the min size we found to all single-valued traces + for(i = 0; i < singleValuedTraces.length; i++) { + tracei = singleValuedTraces[i]; + var calendar = tracei[mainData + 'calendar']; + + tracei._input[binAttr] = tracei[binAttr] = { + start: pa.c2r(dataVals[i] - minSize / 2, 0, calendar), + end: pa.c2r(dataVals[i] + minSize / 2, 0, calendar), + size: minSize + }; + } + + return trace[binAttr]; +} + +/* + * Return an array of histograms that share axes and orientation. + * + * Only considers histograms. In principle we could include bars in a * similar way to how we do manually binned histograms, though this * would have tons of edge cases and value judgments to make. */ function getConnectedHistograms(gd, trace) { - if(gd._fullLayout.barmode === 'overlay') return [trace]; - var xid = trace.xaxis; var yid = trace.yaxis; var orientation = trace.orientation; diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index f2ebb0f4741..3bae4c904ac 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -2401,7 +2401,8 @@ describe('Test axes', function() { expect(out).toEqual({ start: -0.5, end: 2.5, - size: 1 + size: 1, + _count: 3 }); }); @@ -2414,7 +2415,8 @@ describe('Test axes', function() { expect(out).toEqual({ start: undefined, end: undefined, - size: 2 + size: 2, + _count: NaN }); }); @@ -2427,7 +2429,8 @@ describe('Test axes', function() { expect(out).toEqual({ start: undefined, end: undefined, - size: 2 + size: 2, + _count: NaN }); }); @@ -2440,7 +2443,8 @@ describe('Test axes', function() { expect(out).toEqual({ start: undefined, end: undefined, - size: 2 + size: 2, + _count: NaN }); }); @@ -2453,7 +2457,8 @@ describe('Test axes', function() { expect(out).toEqual({ start: 0.5, end: 4.5, - size: 1 + size: 1, + _count: 4 }); }); @@ -2470,7 +2475,8 @@ describe('Test axes', function() { expect(out).toEqual({ start: -0.5, end: 5.5, - size: 2 + size: 2, + _count: 3 }); }); }); diff --git a/test/jasmine/tests/histogram2d_test.js b/test/jasmine/tests/histogram2d_test.js index cc00183b12d..689935f8cec 100644 --- a/test/jasmine/tests/histogram2d_test.js +++ b/test/jasmine/tests/histogram2d_test.js @@ -169,42 +169,42 @@ describe('Test histogram2d', function() { 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4]; Plotly.newPlot(gd, [{type: 'histogram2d', x: x1, y: y1}]); - expect(gd._fullData[0].xbins).toEqual({start: 0.5, end: 4.5, size: 1}); - expect(gd._fullData[0].ybins).toEqual({start: 0.5, end: 4.5, size: 1}); + expect(gd._fullData[0].xbins).toEqual({start: 0.5, end: 4.5, size: 1, _count: 4}); + expect(gd._fullData[0].ybins).toEqual({start: 0.5, end: 4.5, size: 1, _count: 4}); expect(gd._fullData[0].autobinx).toBe(true); expect(gd._fullData[0].autobiny).toBe(true); // same range but fewer samples increases sizes Plotly.restyle(gd, {x: [[1, 3, 4]], y: [[1, 2, 4]]}); - expect(gd._fullData[0].xbins).toEqual({start: -0.5, end: 5.5, size: 2}); - expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 5.5, size: 2}); + expect(gd._fullData[0].xbins).toEqual({start: -0.5, end: 5.5, size: 2, _count: 3}); + expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 5.5, size: 2, _count: 3}); expect(gd._fullData[0].autobinx).toBe(true); expect(gd._fullData[0].autobiny).toBe(true); // larger range Plotly.restyle(gd, {x: [[10, 30, 40]], y: [[10, 20, 40]]}); - expect(gd._fullData[0].xbins).toEqual({start: -0.5, end: 59.5, size: 20}); - expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 59.5, size: 20}); + expect(gd._fullData[0].xbins).toEqual({start: -0.5, end: 59.5, size: 20, _count: 3}); + expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 59.5, size: 20, _count: 3}); expect(gd._fullData[0].autobinx).toBe(true); expect(gd._fullData[0].autobiny).toBe(true); // explicit changes to bin settings Plotly.restyle(gd, 'xbins.start', 12); - expect(gd._fullData[0].xbins).toEqual({start: 12, end: 59.5, size: 20}); - expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 59.5, size: 20}); + expect(gd._fullData[0].xbins).toEqual({start: 12, end: 59.5, size: 20, _count: 3}); + expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 59.5, size: 20, _count: 3}); expect(gd._fullData[0].autobinx).toBe(false); expect(gd._fullData[0].autobiny).toBe(true); Plotly.restyle(gd, {'ybins.end': 12, 'ybins.size': 3}); - expect(gd._fullData[0].xbins).toEqual({start: 12, end: 59.5, size: 20}); - expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 12, size: 3}); + expect(gd._fullData[0].xbins).toEqual({start: 12, end: 59.5, size: 20, _count: 3}); + expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 12, size: 3, _count: 3}); expect(gd._fullData[0].autobinx).toBe(false); expect(gd._fullData[0].autobiny).toBe(false); // restart autobin Plotly.restyle(gd, {autobinx: true, autobiny: true}); - expect(gd._fullData[0].xbins).toEqual({start: -0.5, end: 59.5, size: 20}); - expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 59.5, size: 20}); + expect(gd._fullData[0].xbins).toEqual({start: -0.5, end: 59.5, size: 20, _count: 3}); + expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 59.5, size: 20, _count: 3}); expect(gd._fullData[0].autobinx).toBe(true); expect(gd._fullData[0].autobiny).toBe(true); }); @@ -217,15 +217,15 @@ describe('Test histogram2d', function() { 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4]; Plotly.newPlot(gd, [{type: 'histogram2d', x: x1, y: y1, autobinx: false, autobiny: false}]); - expect(gd._fullData[0].xbins).toEqual({start: 0.5, end: 4.5, size: 1}); - expect(gd._fullData[0].ybins).toEqual({start: 0.5, end: 4.5, size: 1}); + expect(gd._fullData[0].xbins).toEqual({start: 0.5, end: 4.5, size: 1, _count: 4}); + expect(gd._fullData[0].ybins).toEqual({start: 0.5, end: 4.5, size: 1, _count: 4}); expect(gd._fullData[0].autobinx).toBe(false); expect(gd._fullData[0].autobiny).toBe(false); // with autobin false this will no longer update the bins. Plotly.restyle(gd, {x: [[1, 3, 4]], y: [[1, 2, 4]]}); - expect(gd._fullData[0].xbins).toEqual({start: 0.5, end: 4.5, size: 1}); - expect(gd._fullData[0].ybins).toEqual({start: 0.5, end: 4.5, size: 1}); + expect(gd._fullData[0].xbins).toEqual({start: 0.5, end: 4.5, size: 1, _count: 4}); + expect(gd._fullData[0].ybins).toEqual({start: 0.5, end: 4.5, size: 1, _count: 4}); expect(gd._fullData[0].autobinx).toBe(false); expect(gd._fullData[0].autobiny).toBe(false); }); diff --git a/test/jasmine/tests/histogram_test.js b/test/jasmine/tests/histogram_test.js index 8ee79f9fbcb..01f992468f3 100644 --- a/test/jasmine/tests/histogram_test.js +++ b/test/jasmine/tests/histogram_test.js @@ -162,11 +162,13 @@ describe('Test histogram', function() { describe('calc', function() { - function _calc(opts, extraTraces) { + function _calc(opts, extraTraces, layout) { var base = { type: 'histogram' }; var trace = Lib.extendFlat({}, base, opts); var gd = { data: [trace] }; + if(layout) gd.layout = layout; + if(Array.isArray(extraTraces)) { extraTraces.forEach(function(extraTrace) { gd.data.push(Lib.extendFlat({}, base, extraTrace)); @@ -269,6 +271,82 @@ describe('Test histogram', function() { expect(out.length).toEqual(9001); }); + it('handles single-bin data without extra bins', function() { + var out = _calc({ + x: [2.1, 3, 3.9], + xbins: {start: 0, end: 10, size: 2} + }); + + expect(out).toEqual([ + {b: 0, p: 3, s: 3, width1: 2} + ]); + }); + + it('handles single-value overlaid autobinned data with other manual bins', function() { + var out = _calc({x: [1.1, 1.1, 1.1]}, [ + {x: [1, 2, 3, 4], xbins: {start: 0.5, end: 4.5, size: 2}}, + {x: [10, 10.5, 11, 11.5], xbins: {start: 9.8, end: 11.8, size: 0.5}} + ], { + barmode: 'overlay' + }); + + expect(out).toEqual([ + {b: 0, p: 1.1, s: 3, width1: 0.5} + ]); + }); + + it('handles single-value overlaid autobinned data with other auto bins', function() { + var out = _calc({x: ['', null, 17, '', 17]}, [ + {x: [10, 20, 30, 40]}, + {x: [100, 101, 102, 103]} + ], { + barmode: 'overlay' + }); + + expect(out).toEqual([ + {b: 0, p: 17, s: 2, width1: 2} + ]); + }); + + it('handles multiple single-valued overlaid autobinned traces with different values', function() { + var out = _calc({x: [null, 13, '', 13]}, [ + {x: [5]}, + {x: [null, 29, 29, 29, null]} + ], { + barmode: 'overlay' + }); + + expect(out).toEqual([ + {b: 0, p: 13, s: 2, width1: 8} + ]); + }); + + it('handles multiple single-date overlaid autobinned traces with different values', function() { + var out = _calc({x: [null, '2011-02-03', '', '2011-02-03']}, [ + {x: ['2011-02-05']}, + {x: [null, '2015-05-05', '2015-05-05', '2015-05-05', null]} + ], { + barmode: 'overlay' + }); + + expect(out).toEqual([ + {b: 0, p: 1296691200000, s: 2, width1: 2 * 24 * 3600 * 1000} + ]); + }); + + it('handles several overlaid autobinned traces with only one value total', function() { + var out = _calc({x: [null, 97, '', 97]}, [ + {x: [97]}, + {x: [null, 97, 97, 97, null]} + ], { + barmode: 'overlay' + }); + + expect(out).toEqual([ + {b: 0, p: 97, s: 2, width1: 1} + ]); + }); + function calcPositions(opts, extraTraces) { return _calc(opts, extraTraces).map(function(v) { return v.p; }); } @@ -554,5 +632,18 @@ describe('Test histogram', function() { .catch(fail) .then(done); }); + + it('give the right bar width for single-bin histograms', function(done) { + Plotly.newPlot(gd, [{ + type: 'histogram', + x: [3, 3, 3], + xbins: {start: 0, end: 10, size: 2} + }]) + .then(function() { + expect(gd._fullLayout.xaxis.range).toBeCloseToArray([2, 4], 3); + }) + .catch(fail) + .then(done); + }); }); });