diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index f7fb3e83880..721a50afb90 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -237,21 +237,27 @@ Plotly.plot = function(gd, data, layout, config) { return; } - var subplots = Plots.getSubplotIds(fullLayout, 'cartesian'), - modules = fullLayout._modules; + var subplots = Plots.getSubplotIds(fullLayout, 'cartesian'); + var modules = fullLayout._modules; + var setPositionsArray = []; // position and range calculations for traces that // depend on each other ie bars (stacked or grouped) // and boxes (grouped) push each other out of the way - var subplotInfo, _module; + var subplotInfo, i, j; - for(var i = 0; i < subplots.length; i++) { - subplotInfo = fullLayout._plots[subplots[i]]; + for(j = 0; j < modules.length; j++) { + Lib.pushUnique(setPositionsArray, modules[j].setPositions); + } + + if(setPositionsArray.length) { + for(i = 0; i < subplots.length; i++) { + subplotInfo = fullLayout._plots[subplots[i]]; - for(var j = 0; j < modules.length; j++) { - _module = modules[j]; - if(_module.setPositions) _module.setPositions(gd, subplotInfo); + for(j = 0; j < setPositionsArray.length; j++) { + setPositionsArray[j](gd, subplotInfo); + } } } diff --git a/src/traces/histogram/calc.js b/src/traces/histogram/calc.js index 009c59546d5..fee1099eb4c 100644 --- a/src/traces/histogram/calc.js +++ b/src/traces/histogram/calc.js @@ -19,6 +19,7 @@ var binFunctions = require('./bin_functions'); var normFunctions = require('./norm_functions'); var doAvg = require('./average'); var cleanBins = require('./clean_bins'); +var oneMonth = require('../../constants/numerical').ONEAVGMONTH; module.exports = function calc(gd, trace) { @@ -27,99 +28,71 @@ module.exports = function calc(gd, trace) { // depending on orientation, set position and size axes and data ranges // note: this logic for choosing orientation is duplicated in graph_obj->setstyles - var pos = [], - size = [], - i, - pa = Axes.getFromId(gd, - 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'], - cumulativeSpec = trace.cumulative; - - cleanBins(trace, pa, maindata); - - // prepare the raw data - var pos0 = pa.makeCalcdata(trace, maindata); - - // calculate the bins - var binAttr = maindata + 'bins'; - var autoBinAttr = 'autobin' + maindata; - var binspec = trace[binAttr]; - if((trace[autoBinAttr] !== false) || !binspec || - binspec.start === null || binspec.end === null) { - 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 and full data. - trace._input[binAttr] = trace[binAttr] = binspec; - // note that it's possible to get here with an explicit autobin: false - // if the bins were not specified. - // in that case this will remain in the trace, so that future updates - // which would change the autobinning will not do so. - trace._input[autoBinAttr] = trace[autoBinAttr]; - } - - var nonuniformBins = typeof binspec.size === 'string', - bins = nonuniformBins ? [] : binspec, - // make the empty bin array - i2, - binend, - n, - inc = [], - counts = [], - total = 0, - norm = trace.histnorm, - func = trace.histfunc, - densitynorm = norm.indexOf('density') !== -1; - - if(cumulativeSpec.enabled && densitynorm) { + var pos = []; + var size = []; + var pa = Axes.getFromId(gd, trace.orientation === 'h' ? + (trace.yaxis || 'y') : (trace.xaxis || 'x')); + var mainData = trace.orientation === 'h' ? 'y' : 'x'; + var counterData = {x: 'y', y: 'x'}[mainData]; + var calendar = trace[mainData + 'calendar']; + var cumulativeSpec = trace.cumulative; + var i; + + cleanBins(trace, pa, mainData); + + var binsAndPos = calcAllAutoBins(gd, trace, pa, mainData); + var binSpec = binsAndPos[0]; + var pos0 = binsAndPos[1]; + + var nonuniformBins = typeof binSpec.size === 'string'; + var bins = nonuniformBins ? [] : binSpec; + // make the empty bin array + var inc = []; + var counts = []; + var total = 0; + var norm = trace.histnorm; + var func = trace.histfunc; + var densityNorm = norm.indexOf('density') !== -1; + var i2, binEnd, n; + + 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; + densityNorm = false; } - var extremefunc = func === 'max' || func === 'min', - sizeinit = extremefunc ? null : 0, - binfunc = binFunctions.count, - normfunc = normFunctions[norm], - doavg = false, - pr2c = function(v) { return pa.r2c(v, 0, calendar); }, - rawCounterData; - - if(Array.isArray(trace[counterdata]) && func !== 'count') { - rawCounterData = trace[counterdata]; - doavg = func === 'avg'; - binfunc = binFunctions[func]; + var extremeFunc = func === 'max' || func === 'min'; + var sizeInit = extremeFunc ? null : 0; + var binFunc = binFunctions.count; + var normFunc = normFunctions[norm]; + var isAvg = false; + var pr2c = function(v) { return pa.r2c(v, 0, calendar); }; + var rawCounterData; + + if(Array.isArray(trace[counterData]) && func !== 'count') { + rawCounterData = trace[counterData]; + isAvg = func === 'avg'; + binFunc = binFunctions[func]; } // create the bins (and any extra arrays needed) - // assume more than 5000 bins is an error, so we don't crash the browser - i = pr2c(binspec.start); + // assume more than 1e6 bins is an error, so we don't crash the browser + i = pr2c(binSpec.start); // decrease end a little in case of rounding errors - binend = pr2c(binspec.end) + (i - Axes.tickIncrement(i, binspec.size, false, calendar)) / 1e6; + binEnd = pr2c(binSpec.end) + (i - Axes.tickIncrement(i, binSpec.size, false, calendar)) / 1e6; - while(i < binend && pos.length < 1e6) { - i2 = Axes.tickIncrement(i, binspec.size, false, calendar); + while(i < binEnd && pos.length < 1e6) { + i2 = Axes.tickIncrement(i, binSpec.size, false, calendar); pos.push((i + i2) / 2); - size.push(sizeinit); + size.push(sizeInit); // nonuniform bins (like months) we need to search, // rather than straight calculate the bin we're in if(nonuniformBins) bins.push(i); // nonuniform bins also need nonuniform normalization factors - if(densitynorm) inc.push(1 / (i2 - i)); - if(doavg) counts.push(0); + if(densityNorm) inc.push(1 / (i2 - i)); + if(isAvg) counts.push(0); // break to avoid infinite loops if(i2 <= i) break; i = i2; @@ -139,29 +112,30 @@ module.exports = function calc(gd, trace) { // bin the data for(i = 0; i < pos0.length; i++) { n = Lib.findBin(pos0[i], bins); - if(n >= 0 && n < nMax) total += binfunc(n, i, size, rawCounterData, counts); + if(n >= 0 && n < nMax) total += binFunc(n, i, size, rawCounterData, counts); } // average and/or normalize the data, if needed - if(doavg) total = doAvg(size, counts); - if(normfunc) normfunc(size, total, inc); + if(isAvg) 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, - lastNonzero = serieslen - 1; + var seriesLen = Math.min(pos.length, size.length); + var cd = []; + var firstNonzero = 0; + var lastNonzero = seriesLen - 1; + // look for empty bins at the ends to remove, so autoscale omits them - for(i = 0; i < serieslen; i++) { + for(i = 0; i < seriesLen; i++) { if(size[i]) { firstNonzero = i; break; } } - for(i = serieslen - 1; i > firstNonzero; i--) { + for(i = seriesLen - 1; i > firstNonzero; i--) { if(size[i]) { lastNonzero = i; break; @@ -180,10 +154,177 @@ module.exports = function calc(gd, trace) { return cd; }; -function cdf(size, direction, currentbin) { - var i, - vi, - prevSum; +/* + * calcAllAutoBins: we want all histograms on the same axes to share bin specs + * if they're grouped or stacked. If the user has explicitly specified differing + * bin specs, there's nothing we can do, but if possible we will try to use the + * smallest bins of any of the auto values for all histograms grouped/stacked + * together. + */ +function calcAllAutoBins(gd, trace, pa, mainData) { + var binAttr = mainData + 'bins'; + var i, tracei, calendar, firstManual, pos0; + + // all but the first trace in this group has already been marked finished + // clear this flag, so next time we run calc we will run autobin again + if(trace._autoBinFinished) { + delete trace._autoBinFinished; + } + else { + // must be the first trace in the group - do the autobinning on them all + var traceGroup = getConnectedHistograms(gd, trace); + var autoBinnedTraces = []; + + var minSize = Infinity; + var minStart = Infinity; + var maxEnd = -Infinity; + + var autoBinAttr = 'autobin' + mainData; + + for(i = 0; i < traceGroup.length; i++) { + tracei = traceGroup[i]; + + // stash pos0 on the trace so we don't need to duplicate this + // in the main body of calc + pos0 = tracei._pos0 = pa.makeCalcdata(tracei, mainData); + var binSpec = tracei[binAttr]; + + if((tracei[autoBinAttr]) || !binSpec || + binSpec.start === null || binSpec.end === null) { + calendar = tracei[mainData + 'calendar']; + var cumulativeSpec = tracei.cumulative; + + binSpec = Axes.autoBin(pos0, pa, tracei['nbins' + mainData], false, calendar); + + // adjust for CDF edge cases + if(cumulativeSpec.enabled && (cumulativeSpec.currentbin !== 'include')) { + if(cumulativeSpec.direction === 'decreasing') { + minStart = Math.min(minStart, pa.r2c(binSpec.start, 0, calendar) - binSpec.size); + } + else { + maxEnd = Math.max(maxEnd, pa.r2c(binSpec.end, 0, calendar) + binSpec.size); + } + } + + // note that it's possible to get here with an explicit autobin: false + // if the bins were not specified. mark this trace for followup + autoBinnedTraces.push(tracei); + } + 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... + firstManual = { + size: binSpec.size, + start: pa.r2c(binSpec.start, 0, calendar), + end: pa.r2c(binSpec.end, 0, calendar) + }; + } + + // Even non-autobinned traces get included here, so we get the greatest extent + // and minimum bin size of them all. + // But manually binned traces won't be adjusted, even if the auto values + // are inconsistent with the manual ones (or the manual ones are inconsistent + // with each other). + minSize = getMinSize(minSize, binSpec.size); + minStart = Math.min(minStart, pa.r2c(binSpec.start, 0, calendar)); + maxEnd = Math.max(maxEnd, pa.r2c(binSpec.end, 0, calendar)); + + // add the flag that lets us abort autobin on later traces + if(i) trace._autoBinFinished = 1; + } + + // do what we can to match the auto bins to the first manual bins + // but only if sizes are all numeric + if(firstManual && isNumeric(firstManual.size) && isNumeric(minSize)) { + // first need to ensure the bin size is the same as or an integer fraction + // of the first manual bin + // allow the bin size to increase just under the autobin step size to match, + // (which is a factor of 2 or 2.5) otherwise shrink it + if(minSize > firstManual.size / 1.9) minSize = firstManual.size; + else minSize = firstManual.size / Math.ceil(firstManual.size / minSize); + + // now decrease minStart if needed to make the bin centers line up + var adjustedFirstStart = firstManual.start + (firstManual.size - minSize) / 2; + minStart = adjustedFirstStart - minSize * Math.ceil((adjustedFirstStart - minStart) / minSize); + } + + // now go back to the autobinned traces and update their bin specs with the final values + for(i = 0; i < autoBinnedTraces.length; i++) { + tracei = autoBinnedTraces[i]; + calendar = tracei[mainData + 'calendar']; + + tracei._input[binAttr] = tracei[binAttr] = { + start: pa.c2r(minStart, 0, calendar), + end: pa.c2r(maxEnd, 0, calendar), + size: minSize + }; + + // note that it's possible to get here with an explicit autobin: false + // if the bins were not specified. + // in that case this will remain in the trace, so that future updates + // which would change the autobinning will not do so. + tracei._input[autoBinAttr] = tracei[autoBinAttr]; + } + } + + pos0 = trace._pos0; + delete trace._pos0; + + return [trace[binAttr], pos0]; +} + +/* + * Return an array of traces that are all stacked or grouped together + * Only considers histograms. In principle we could include them 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; + + var out = []; + var fullData = gd._fullData; + for(var i = 0; i < fullData.length; i++) { + var tracei = fullData[i]; + if(tracei.type === 'histogram' && + tracei.orientation === orientation && + tracei.xaxis === xid && tracei.yaxis === yid + ) { + out.push(tracei); + } + } + + return out; +} + + +/* + * getMinSize: find the smallest given that size can be a string code + * ie 'M6' for 6 months. ('L' wouldn't make sense to compare with numeric sizes) + */ +function getMinSize(size1, size2) { + if(size1 === Infinity) return size2; + var sizeNumeric1 = numericSize(size1); + var sizeNumeric2 = numericSize(size2); + return sizeNumeric2 < sizeNumeric1 ? size2 : size1; +} + +function numericSize(size) { + if(isNumeric(size)) return size; + if(typeof size === 'string' && size.charAt(0) === 'M') { + return oneMonth * +(size.substr(1)); + } + return Infinity; +} + +function cdf(size, direction, currentBin) { + var i, vi, prevSum; function firstHalfPoint(i) { prevSum = size[i]; @@ -196,7 +337,7 @@ function cdf(size, direction, currentbin) { prevSum += vi; } - if(currentbin === 'half') { + if(currentBin === 'half') { if(direction === 'increasing') { firstHalfPoint(0); @@ -217,7 +358,7 @@ function cdf(size, direction, currentbin) { } // 'exclude' is identical to 'include' just shifted one bin over - if(currentbin === 'exclude') { + if(currentBin === 'exclude') { size.unshift(0); size.pop(); } @@ -227,7 +368,7 @@ function cdf(size, direction, currentbin) { size[i] += size[i + 1]; } - if(currentbin === 'exclude') { + if(currentBin === 'exclude') { size.push(0); size.shift(); } diff --git a/test/image/baselines/hist_grouped.png b/test/image/baselines/hist_grouped.png index afe4759741f..a5d9e71a794 100644 Binary files a/test/image/baselines/hist_grouped.png and b/test/image/baselines/hist_grouped.png differ diff --git a/test/image/baselines/hist_stacked.png b/test/image/baselines/hist_stacked.png index 6c549fc6817..72cb9a5ec04 100644 Binary files a/test/image/baselines/hist_stacked.png and b/test/image/baselines/hist_stacked.png differ diff --git a/test/image/mocks/hist_grouped.json b/test/image/mocks/hist_grouped.json index 6c1a7a6ade1..e41f2042a2b 100644 --- a/test/image/mocks/hist_grouped.json +++ b/test/image/mocks/hist_grouped.json @@ -1,10 +1,10 @@ { "data":[{ - "x":["1","2","3","4"], - "type":"histogram" - },{ - "x":["1","2","3","4"], - "type":"histogram" + "x": [1, 1, 1, 2, 2], + "type": "histogram" + }, { + "x": [1, 2, 3, 4], + "type": "histogram" }], - "layout":{"height":300,"width":400} + "layout": {"height": 300, "width": 400} } diff --git a/test/image/mocks/hist_stacked.json b/test/image/mocks/hist_stacked.json index 59b3c32d640..5588ec4989e 100644 --- a/test/image/mocks/hist_stacked.json +++ b/test/image/mocks/hist_stacked.json @@ -1,10 +1,10 @@ { - "data":[{ - "x":["1","2","3","4"], - "type":"histogram" - },{ - "x":["1","2","3","4"], - "type":"histogram" + "data": [{ + "x": [1, 1, 1, 2, 2], + "type": "histogram" + }, { + "x": [1, 2, 3, 4], + "type": "histogram" }], - "layout":{"height":300,"width":400,"barmode":"stack"} + "layout": {"height": 300, "width": 400, "barmode": "stack"} } diff --git a/test/jasmine/tests/histogram_test.js b/test/jasmine/tests/histogram_test.js index 922adf4d497..efd03c6da4b 100644 --- a/test/jasmine/tests/histogram_test.js +++ b/test/jasmine/tests/histogram_test.js @@ -7,6 +7,7 @@ var calc = require('@src/traces/histogram/calc'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); +var customMatchers = require('../assets/custom_matchers'); describe('Test histogram', function() { @@ -162,10 +163,20 @@ describe('Test histogram', function() { describe('calc', function() { - function _calc(opts) { - var base = { type: 'histogram' }, - trace = Lib.extendFlat({}, base, opts), - gd = { data: [trace] }; + beforeAll(function() { + jasmine.addMatchers(customMatchers); + }); + + function _calc(opts, extraTraces) { + var base = { type: 'histogram' }; + var trace = Lib.extendFlat({}, base, opts); + var gd = { data: [trace] }; + + if(Array.isArray(extraTraces)) { + extraTraces.forEach(function(extraTrace) { + gd.data.push(Lib.extendFlat({}, base, extraTrace)); + }); + } Plots.supplyDefaults(gd); var fullTrace = gd._fullData[0]; @@ -263,6 +274,85 @@ describe('Test histogram', function() { expect(out.length).toEqual(9001); }); + function calcPositions(opts, extraTraces) { + return _calc(opts, extraTraces).map(function(v) { return v.p; }); + } + + it('harmonizes autobins when all traces are autobinned', function() { + var trace1 = {x: [1, 2, 3, 4]}; + var trace2 = {x: [5, 5.5, 6, 6.5]}; + + expect(calcPositions(trace1)).toBeCloseToArray([0.5, 2.5, 4.5], 5); + + expect(calcPositions(trace2)).toBeCloseToArray([5.5, 6.5], 5); + + expect(calcPositions(trace1, [trace2])).toEqual([1, 2, 3, 4]); + // huh, turns out even this one is an example of "unexpected bin positions" + // (see another example below) - in this case it's because trace1 gets + // autoshifted to keep integers off the bin edges, whereas trace2 doesn't + // because there are as many integers as half-integers. + // In this case though, it's unexpected but arguably better than the + // "expected" result. + expect(calcPositions(trace2, [trace1])).toEqual([5, 6, 7]); + }); + + it('can sometimes give unexpected bin positions', function() { + // documenting an edge case that might not be desirable but for now + // we've decided to ignore: a larger bin sets the bin start, but then it + // doesn't quite make sense with the smaller bin we end up with + // we *could* fix this by ensuring that the bin start is based on the + // same bin spec that gave the minimum bin size, but incremented down to + // include the minimum start... but that would have awkward edge cases + // involving month bins so for now we're ignoring it. + + // all integers, so all autobins should get shifted to start 0.5 lower + // than they otherwise would. + var trace1 = {x: [1, 2, 3, 4]}; + var trace2 = {x: [-2, 1, 4, 7]}; + + // as above... size: 2 + expect(calcPositions(trace1)).toBeCloseToArray([0.5, 2.5, 4.5], 5); + + // {size: 5, start: -5.5}: -5..-1, 0..4, 5..9 + expect(calcPositions(trace2)).toEqual([-3, 2, 7]); + + // unexpected behavior when we put these together, + // because 2 and 5 are mutually prime. Normally you could never get + // groupings 1&2, 3&4... you'd always get 0&1, 2&3... + expect(calcPositions(trace1, [trace2])).toBeCloseToArray([1.5, 3.5], 5); + expect(calcPositions(trace2, [trace1])).toBeCloseToArray([ + -2.5, -0.5, 1.5, 3.5, 5.5, 7.5 + ], 5); + }); + + it('harmonizes autobins with smaller manual bins', function() { + var trace1 = {x: [1, 2, 3, 4]}; + var trace2 = {x: [5, 6, 7, 8], xbins: {start: 4.3, end: 7.1, size: 0.4}}; + + expect(calcPositions(trace1, [trace2])).toBeCloseToArray([ + 0.9, 1.3, 1.7, 2.1, 2.5, 2.9, 3.3, 3.7, 4.1 + ], 5); + }); + + it('harmonizes autobins with larger manual bins', function() { + var trace1 = {x: [1, 2, 3, 4]}; + var trace2 = {x: [5, 6, 7, 8], xbins: {start: 4.3, end: 15, size: 7}}; + + expect(calcPositions(trace1, [trace2])).toBeCloseToArray([ + 0.8, 2.55, 4.3 + ], 5); + }); + + it('ignores traces on other axes', function() { + var trace1 = {x: [1, 2, 3, 4]}; + var trace2 = {x: [5, 5.5, 6, 6.5]}; + var trace3 = {x: [1, 1.1, 1.2, 1.3], xaxis: 'x2'}; + var trace4 = {x: [1, 1.2, 1.4, 1.6], yaxis: 'y2'}; + + expect(calcPositions(trace1, [trace2, trace3, trace4])).toEqual([1, 2, 3, 4]); + expect(calcPositions(trace3)).toBeCloseToArray([0.9, 1.1, 1.3], 5); + }); + describe('cumulative distribution functions', function() { var base = { x: [0, 5, 10, 15, 5, 10, 15, 10, 15, 15],