diff --git a/src/lib/dates.js b/src/lib/dates.js index 67828b32837..1027a797928 100644 --- a/src/lib/dates.js +++ b/src/lib/dates.js @@ -345,7 +345,9 @@ function includeTime(dateStr, h, m, s, msec10) { // a Date object or milliseconds // optional dflt is the return value if cleaning fails exports.cleanDate = function(v, dflt, calendar) { - if(exports.isJSDate(v) || typeof v === 'number') { + // let us use cleanDate to provide a missing default without an error + if(v === BADNUM) return dflt; + if(exports.isJSDate(v) || (typeof v === 'number' && isFinite(v))) { // do not allow milliseconds (old) or jsdate objects (inherently // described as gregorian dates) with world calendars if(isWorldCalendar(calendar)) { diff --git a/src/plot_api/helpers.js b/src/plot_api/helpers.js index 8a49ae911ae..6a4208e1cde 100644 --- a/src/plot_api/helpers.js +++ b/src/plot_api/helpers.js @@ -386,6 +386,19 @@ exports.cleanData = function(data) { // sanitize rgb(fractions) and rgba(fractions) that old tinycolor // supported, but new tinycolor does not because they're not valid css Color.clean(trace); + + // remove obsolete autobin(x|y) attributes, but only if true + // if false, this needs to happen in Histogram.calc because it + // can be a one-time autobin so we need to know the results before + // we can push them back into the trace. + if(trace.autobinx) { + delete trace.autobinx; + delete trace.xbins; + } + if(trace.autobiny) { + delete trace.autobiny; + delete trace.ybins; + } } }; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 9bd8007dfc2..97014e69014 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -1434,6 +1434,18 @@ function _restyle(gd, aobj, traces) { } } + function allBins(binAttr) { + return function(j) { + return fullData[j][binAttr]; + }; + } + + function arrayBins(binAttr) { + return function(vij, j) { + return vij === false ? fullData[traces[j]][binAttr] : null; + }; + } + // now make the changes to gd.data (and occasionally gd.layout) // and figure out what kind of graphics update we need to do for(var ai in aobj) { @@ -1449,6 +1461,17 @@ function _restyle(gd, aobj, traces) { newVal, valObject; + // Backward compatibility shim for turning histogram autobin on, + // or freezing previous autobinned values. + // Replace obsolete `autobin(x|y): true` with `(x|y)bins: null` + // and `autobin(x|y): false` with the `(x|y)bins` in `fullData` + if(ai === 'autobinx' || ai === 'autobiny') { + ai = ai.charAt(ai.length - 1) + 'bins'; + if(Array.isArray(vi)) vi = vi.map(arrayBins(ai)); + else if(vi === false) vi = traces.map(allBins(ai)); + else vi = null; + } + redoit[ai] = vi; if(ai.substr(0, 6) === 'LAYOUT') { @@ -1609,8 +1632,12 @@ function _restyle(gd, aobj, traces) { } } - // major enough changes deserve autoscale, autobin, and + // Major enough changes deserve autoscale and // non-reversed axes so people don't get confused + // + // Note: autobin (or its new analog bin clearing) is not included here + // since we're not pushing bins back to gd.data, so if we have bin + // info it was explicitly provided by the user. if(['orientation', 'type'].indexOf(ai) !== -1) { axlist = []; for(i = 0; i < traces.length; i++) { @@ -1619,10 +1646,6 @@ function _restyle(gd, aobj, traces) { if(Registry.traceIs(trace, 'cartesian')) { addToAxlist(trace.xaxis || 'x'); addToAxlist(trace.yaxis || 'y'); - - if(ai === 'type') { - doextra(['autobinx', 'autobiny'], true, i); - } } } diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index f8d1b4e8369..c2352fcdc33 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -21,6 +21,7 @@ var Color = require('../../components/color'); var Drawing = require('../../components/drawing'); var axAttrs = require('./layout_attributes'); +var cleanTicks = require('./clean_ticks'); var constants = require('../../constants/numerical'); var ONEAVGYEAR = constants.ONEAVGYEAR; @@ -280,43 +281,22 @@ axes.saveShowSpikeInitial = function(gd, overwrite) { return hasOneAxisChanged; }; -axes.autoBin = function(data, ax, nbins, is2d, calendar) { - var dataMin = Lib.aggNums(Math.min, null, data), - dataMax = Lib.aggNums(Math.max, null, data); - - if(!calendar) calendar = ax.calendar; +axes.autoBin = function(data, ax, nbins, is2d, calendar, size) { + var dataMin = Lib.aggNums(Math.min, null, data); + var dataMax = Lib.aggNums(Math.max, null, data); if(ax.type === 'category') { return { start: dataMin - 0.5, end: dataMax + 0.5, - size: 1, + size: Math.max(1, Math.round(size) || 1), _dataSpan: dataMax - dataMin, }; } - var size0; - if(nbins) size0 = ((dataMax - dataMin) / nbins); - else { - // totally auto: scale off std deviation so the highest bin is - // somewhat taller than the total number of bins, but don't let - // the size get smaller than the 'nice' rounded down minimum - // difference between values - var distinctData = Lib.distinctVals(data), - msexp = Math.pow(10, Math.floor( - Math.log(distinctData.minDiff) / Math.LN10)), - minSize = msexp * Lib.roundUp( - distinctData.minDiff / msexp, [0.9, 1.9, 4.9, 9.9], true); - size0 = Math.max(minSize, 2 * Lib.stdev(data) / - Math.pow(data.length, is2d ? 0.25 : 0.4)); - - // fallback if ax.d2c output BADNUMs - // e.g. when user try to plot categorical bins - // on a layout.xaxis.type: 'linear' - if(!isNumeric(size0)) size0 = 1; - } + if(!calendar) calendar = ax.calendar; - // piggyback off autotick code to make "nice" bin sizes + // piggyback off tick code to make "nice" bin sizes and edges var dummyAx; if(ax.type === 'log') { dummyAx = { @@ -333,19 +313,51 @@ axes.autoBin = function(data, ax, nbins, is2d, calendar) { } axes.setConvert(dummyAx); - axes.autoTicks(dummyAx, size0); + size = size && cleanTicks.dtick(size, dummyAx.type); + + if(size) { + dummyAx.dtick = size; + dummyAx.tick0 = cleanTicks.tick0(undefined, dummyAx.type, calendar); + } + else { + var size0; + if(nbins) size0 = ((dataMax - dataMin) / nbins); + else { + // totally auto: scale off std deviation so the highest bin is + // somewhat taller than the total number of bins, but don't let + // the size get smaller than the 'nice' rounded down minimum + // difference between values + var distinctData = Lib.distinctVals(data); + var msexp = Math.pow(10, Math.floor( + Math.log(distinctData.minDiff) / Math.LN10)); + var minSize = msexp * Lib.roundUp( + distinctData.minDiff / msexp, [0.9, 1.9, 4.9, 9.9], true); + size0 = Math.max(minSize, 2 * Lib.stdev(data) / + Math.pow(data.length, is2d ? 0.25 : 0.4)); + + // fallback if ax.d2c output BADNUMs + // e.g. when user try to plot categorical bins + // on a layout.xaxis.type: 'linear' + if(!isNumeric(size0)) size0 = 1; + } + + axes.autoTicks(dummyAx, size0); + } + + + var finalSize = dummyAx.dtick; var binStart = axes.tickIncrement( - axes.tickFirst(dummyAx), dummyAx.dtick, 'reverse', calendar); + axes.tickFirst(dummyAx), finalSize, '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 // and offset the bins accordingly - if(typeof dummyAx.dtick === 'number') { + if(typeof finalSize === 'number') { binStart = autoShiftNumericBins(binStart, data, dummyAx, dataMin, dataMax); - bincount = 1 + Math.floor((dataMax - binStart) / dummyAx.dtick); - binEnd = binStart + bincount * dummyAx.dtick; + bincount = 1 + Math.floor((dataMax - binStart) / finalSize); + binEnd = binStart + bincount * finalSize; } else { // month ticks - should be the only nonlinear kind we have at this point. @@ -354,7 +366,7 @@ axes.autoBin = function(data, ax, nbins, is2d, calendar) { // we bin it on a linear axis (which one could argue against, but that's // a separate issue) if(dummyAx.dtick.charAt(0) === 'M') { - binStart = autoShiftMonthBins(binStart, data, dummyAx.dtick, dataMin, calendar); + binStart = autoShiftMonthBins(binStart, data, finalSize, dataMin, calendar); } // calculate the endpoint for nonlinear ticks - you have to @@ -362,7 +374,7 @@ axes.autoBin = function(data, ax, nbins, is2d, calendar) { binEnd = binStart; bincount = 0; while(binEnd <= dataMax) { - binEnd = axes.tickIncrement(binEnd, dummyAx.dtick, false, calendar); + binEnd = axes.tickIncrement(binEnd, finalSize, false, calendar); bincount++; } } @@ -370,7 +382,7 @@ axes.autoBin = function(data, ax, nbins, is2d, calendar) { return { start: ax.c2r(binStart, 0, calendar), end: ax.c2r(binEnd, 0, calendar), - size: dummyAx.dtick, + size: finalSize, _dataSpan: dataMax - dataMin }; }; diff --git a/src/plots/cartesian/clean_ticks.js b/src/plots/cartesian/clean_ticks.js new file mode 100644 index 00000000000..a6a51bef9a7 --- /dev/null +++ b/src/plots/cartesian/clean_ticks.js @@ -0,0 +1,87 @@ +/** +* Copyright 2012-2018, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var isNumeric = require('fast-isnumeric'); +var Lib = require('../../lib'); +var ONEDAY = require('../../constants/numerical').ONEDAY; + +/** + * Return a validated dtick value for this axis + * + * @param {any} dtick: the candidate dtick. valid values are numbers and strings, + * and further constrained depending on the axis type. + * @param {string} axType: the axis type + */ +exports.dtick = function(dtick, axType) { + var isLog = axType === 'log'; + var isDate = axType === 'date'; + var isCat = axType === 'category'; + var dtickDflt = isDate ? ONEDAY : 1; + + if(!dtick) return dtickDflt; + + if(isNumeric(dtick)) { + dtick = Number(dtick); + if(dtick <= 0) return dtickDflt; + if(isCat) { + // category dtick must be positive integers + return Math.max(1, Math.round(dtick)); + } + if(isDate) { + // date dtick must be at least 0.1ms (our current precision) + return Math.max(0.1, dtick); + } + return dtick; + } + + if(typeof dtick !== 'string' || !(isDate || isLog)) { + return dtickDflt; + } + + var prefix = dtick.charAt(0); + var dtickNum = dtick.substr(1); + dtickNum = isNumeric(dtickNum) ? Number(dtickNum) : 0; + + if((dtickNum <= 0) || !( + // "M" gives ticks every (integer) n months + (isDate && prefix === 'M' && dtickNum === Math.round(dtickNum)) || + // "L" gives ticks linearly spaced in data (not in position) every (float) f + (isLog && prefix === 'L') || + // "D1" gives powers of 10 with all small digits between, "D2" gives only 2 and 5 + (isLog && prefix === 'D' && (dtickNum === 1 || dtickNum === 2)) + )) { + return dtickDflt; + } + + return dtick; +}; + +/** + * Return a validated tick0 for this axis + * + * @param {any} tick0: the candidate tick0. Valid values are numbers and strings, + * further constrained depending on the axis type + * @param {string} axType: the axis type + * @param {string} calendar: for date axes, the calendar to validate/convert with + * @param {any} dtick: an already valid dtick. Only used for D1 and D2 log dticks, + * which do not support tick0 at all. + */ +exports.tick0 = function(tick0, axType, calendar, dtick) { + if(axType === 'date') { + return Lib.cleanDate(tick0, Lib.dateTick0(calendar)); + } + if(dtick === 'D1' || dtick === 'D2') { + // D1 and D2 modes ignore tick0 entirely + return undefined; + } + // Aside from date axes, tick0 must be numeric + return isNumeric(tick0) ? Number(tick0) : 0; +}; diff --git a/src/plots/cartesian/tick_value_defaults.js b/src/plots/cartesian/tick_value_defaults.js index ca3e1aa356d..f5aff20aefa 100644 --- a/src/plots/cartesian/tick_value_defaults.js +++ b/src/plots/cartesian/tick_value_defaults.js @@ -9,9 +9,7 @@ 'use strict'; -var isNumeric = require('fast-isnumeric'); -var Lib = require('../../lib'); -var ONEDAY = require('../../constants/numerical').ONEDAY; +var cleanTicks = require('./clean_ticks'); module.exports = function handleTickValueDefaults(containerIn, containerOut, coerce, axType) { @@ -33,47 +31,11 @@ module.exports = function handleTickValueDefaults(containerIn, containerOut, coe else if(tickmode === 'linear') { // dtick is usually a positive number, but there are some // special strings available for log or date axes - // default is 1 day for dates, otherwise 1 - var dtickDflt = (axType === 'date') ? ONEDAY : 1; - var dtick = coerce('dtick', dtickDflt); - if(isNumeric(dtick)) { - containerOut.dtick = (dtick > 0) ? Number(dtick) : dtickDflt; - } - else if(typeof dtick !== 'string') { - containerOut.dtick = dtickDflt; - } - else { - // date and log special cases are all one character plus a number - var prefix = dtick.charAt(0), - dtickNum = dtick.substr(1); - - dtickNum = isNumeric(dtickNum) ? Number(dtickNum) : 0; - if((dtickNum <= 0) || !( - // "M" gives ticks every (integer) n months - (axType === 'date' && prefix === 'M' && dtickNum === Math.round(dtickNum)) || - // "L" gives ticks linearly spaced in data (not in position) every (float) f - (axType === 'log' && prefix === 'L') || - // "D1" gives powers of 10 with all small digits between, "D2" gives only 2 and 5 - (axType === 'log' && prefix === 'D' && (dtickNum === 1 || dtickNum === 2)) - )) { - containerOut.dtick = dtickDflt; - } - } - - // tick0 can have different valType for different axis types, so - // validate that now. Also for dates, change milliseconds to date strings - var tick0Dflt = (axType === 'date') ? Lib.dateTick0(containerOut.calendar) : 0; - var tick0 = coerce('tick0', tick0Dflt); - if(axType === 'date') { - containerOut.tick0 = Lib.cleanDate(tick0, tick0Dflt); - } - // Aside from date axes, dtick must be numeric; D1 and D2 modes ignore tick0 entirely - else if(isNumeric(tick0) && dtick !== 'D1' && dtick !== 'D2') { - containerOut.tick0 = Number(tick0); - } - else { - containerOut.tick0 = tick0Dflt; - } + // tick0 also has special logic + var dtick = containerOut.dtick = cleanTicks.dtick( + containerIn.dtick, axType); + containerOut.tick0 = cleanTicks.tick0( + containerIn.tick0, axType, containerOut.calendar, dtick); } else { var tickvals = coerce('tickvals'); diff --git a/src/plots/plots.js b/src/plots/plots.js index 4cc227fb3cb..246f518de3e 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -428,13 +428,6 @@ plots.supplyDefaults = function(gd, opts) { // attach helper method to check whether a plot type is present on graph newFullLayout._has = plots._hasPlotType.bind(newFullLayout); - // special cases that introduce interactions between traces - var _modules = newFullLayout._visibleModules; - for(i = 0; i < _modules.length; i++) { - var _module = _modules[i]; - if(_module.cleanData) _module.cleanData(newFullData); - } - if(oldFullData.length === newFullData.length) { for(i = 0; i < newFullData.length; i++) { relinkPrivateKeys(newFullData[i], oldFullData[i]); @@ -444,6 +437,20 @@ plots.supplyDefaults = function(gd, opts) { // finally, fill in the pieces of layout that may need to look at data plots.supplyLayoutModuleDefaults(newLayout, newFullLayout, newFullData, gd._transitionData); + // Special cases that introduce interactions between traces. + // This is after relinkPrivateKeys so we can use those in crossTraceDefaults + // and after layout module defaults, so we can use eg barmode + var _modules = newFullLayout._visibleModules; + var crossTraceDefaultsFuncs = []; + for(i = 0; i < _modules.length; i++) { + var funci = _modules[i].crossTraceDefaults; + // some trace types share crossTraceDefaults (ie histogram2d, histogram2dcontour) + if(funci) Lib.pushUnique(crossTraceDefaultsFuncs, funci); + } + for(i = 0; i < crossTraceDefaultsFuncs.length; i++) { + crossTraceDefaultsFuncs[i](newFullData, newFullLayout); + } + // turn on flag to optimize large splom-only graphs // mostly by omitting SVG layers during Cartesian.drawFramework newFullLayout._hasOnlyLargeSploms = ( @@ -1482,7 +1489,10 @@ plots.supplyLayoutModuleDefaults = function(layoutIn, layoutOut, fullData, trans } // trace module layout defaults - var modules = layoutOut._visibleModules; + // use _modules rather than _visibleModules so that even + // legendonly traces can include settings - eg barmode, which affects + // legend.traceorder default value. + var modules = layoutOut._modules; for(i = 0; i < modules.length; i++) { _module = modules[i]; diff --git a/src/traces/bar/layout_defaults.js b/src/traces/bar/layout_defaults.js index 274b0696282..4809ce1d035 100644 --- a/src/traces/bar/layout_defaults.js +++ b/src/traces/bar/layout_defaults.js @@ -28,7 +28,7 @@ module.exports = function(layoutIn, layoutOut, fullData) { for(var i = 0; i < fullData.length; i++) { var trace = fullData[i]; - if(Registry.traceIs(trace, 'bar')) hasBars = true; + if(Registry.traceIs(trace, 'bar') && trace.visible) hasBars = true; else continue; // if we have at least 2 grouped bar traces on the same subplot, diff --git a/src/traces/histogram/attributes.js b/src/traces/histogram/attributes.js index f15e85b6fc2..80bf57e1585 100644 --- a/src/traces/histogram/attributes.js +++ b/src/traces/histogram/attributes.js @@ -9,6 +9,7 @@ 'use strict'; var barAttrs = require('../bar/attributes'); +var makeBinAttrs = require('./bin_attributes'); module.exports = { x: { @@ -125,25 +126,22 @@ module.exports = { }, editType: 'calc' }, - - autobinx: { - valType: 'boolean', - dflt: null, + nbinsx: { + valType: 'integer', + min: 0, + dflt: 0, role: 'style', editType: 'calc', - impliedEdits: { - 'xbins.start': undefined, - 'xbins.end': undefined, - 'xbins.size': undefined - }, description: [ - 'Determines whether or not the x axis bin attributes are picked', - 'by an algorithm. Note that this should be set to false if you', - 'want to manually set the number of bins using the attributes in', - 'xbins.' + 'Specifies the maximum number of desired bins. This value will be used', + 'in an algorithm that will decide the optimal bin size such that the', + 'histogram best visualizes the distribution of the data.', + 'Ignored if `xbins.size` is provided.' ].join(' ') }, - nbinsx: { + xbins: makeBinAttrs('x', true), + + nbinsy: { valType: 'integer', min: 0, dflt: 0, @@ -152,11 +150,23 @@ module.exports = { description: [ 'Specifies the maximum number of desired bins. This value will be used', 'in an algorithm that will decide the optimal bin size such that the', - 'histogram best visualizes the distribution of the data.' + 'histogram best visualizes the distribution of the data.', + 'Ignored if `ybins.size` is provided.' + ].join(' ') + }, + ybins: makeBinAttrs('y', true), + autobinx: { + valType: 'boolean', + dflt: null, + role: 'style', + editType: 'calc', + description: [ + 'Obsolete: since v1.42 each bin attribute is auto-determined', + 'separately and `autobinx` is not needed. However, we accept', + '`autobinx: true` or `false` and will update `xbins` accordingly', + 'before deleting `autobinx` from the trace.' ].join(' ') }, - xbins: makeBinsAttr('x'), - autobiny: { valType: 'boolean', dflt: null, @@ -168,25 +178,12 @@ module.exports = { 'ybins.size': undefined }, description: [ - 'Determines whether or not the y axis bin attributes are picked', - 'by an algorithm. Note that this should be set to false if you', - 'want to manually set the number of bins using the attributes in', - 'ybins.' + 'Obsolete: since v1.42 each bin attribute is auto-determined', + 'separately and `autobiny` is not needed. However, we accept', + '`autobiny: true` or `false` and will update `ybins` accordingly', + 'before deleting `autobiny` from the trace.' ].join(' ') }, - nbinsy: { - valType: 'integer', - min: 0, - dflt: 0, - role: 'style', - editType: 'calc', - description: [ - 'Specifies the maximum number of desired bins. This value will be used', - 'in an algorithm that will decide the optimal bin size such that the', - 'histogram best visualizes the distribution of the data.' - ].join(' ') - }, - ybins: makeBinsAttr('y'), marker: barAttrs.marker, @@ -197,48 +194,3 @@ module.exports = { bardir: barAttrs._deprecated.bardir } }; - -function makeBinsAttr(axLetter) { - var impliedEdits = {}; - impliedEdits['autobin' + axLetter] = false; - var impliedEditsInner = {}; - impliedEditsInner['^autobin' + axLetter] = false; - - return { - start: { - valType: 'any', // for date axes - dflt: null, - role: 'style', - editType: 'calc', - impliedEdits: impliedEditsInner, - description: [ - 'Sets the starting value for the', axLetter, - 'axis bins.' - ].join(' ') - }, - end: { - valType: 'any', // for date axes - dflt: null, - role: 'style', - editType: 'calc', - impliedEdits: impliedEditsInner, - description: [ - 'Sets the end value for the', axLetter, - 'axis bins.' - ].join(' ') - }, - size: { - valType: 'any', // for date axes - dflt: null, - role: 'style', - editType: 'calc', - impliedEdits: impliedEditsInner, - description: [ - 'Sets the step in-between value each', axLetter, - 'axis bin.' - ].join(' ') - }, - editType: 'calc', - impliedEdits: impliedEdits - }; -} diff --git a/src/traces/histogram/bin_attributes.js b/src/traces/histogram/bin_attributes.js new file mode 100644 index 00000000000..24c800477b8 --- /dev/null +++ b/src/traces/histogram/bin_attributes.js @@ -0,0 +1,74 @@ +/** +* Copyright 2012-2018, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +module.exports = function makeBinAttrs(axLetter, match) { + return { + start: { + valType: 'any', // for date axes + role: 'style', + editType: 'calc', + description: [ + 'Sets the starting value for the', axLetter, + 'axis bins. Defaults to the minimum data value,', + 'shifted down if necessary to make nice round values', + 'and to remove ambiguous bin edges. For example, if most of the', + 'data is integers we shift the bin edges 0.5 down, so a `size`', + 'of 5 would have a default `start` of -0.5, so it is clear', + 'that 0-4 are in the first bin, 5-9 in the second, but', + 'continuous data gets a start of 0 and bins [0,5), [5,10) etc.', + 'Dates behave similarly, and `start` should be a date string.', + 'For category data, `start` is based on the category serial', + 'numbers, and defaults to -0.5.', + (match ? ( + 'If multiple non-overlaying histograms share a subplot, ' + + 'the first explicit `start` is used exactly and all others ' + + 'are shifted down (if necessary) to differ from that one ' + + 'by an integer number of bins.' + ) : '') + ].join(' ') + }, + end: { + valType: 'any', // for date axes + role: 'style', + editType: 'calc', + description: [ + 'Sets the end value for the', axLetter, + 'axis bins. The last bin may not end exactly at this value,', + 'we increment the bin edge by `size` from `start` until we', + 'reach or exceed `end`. Defaults to the maximum data value.', + 'Like `start`, for dates use a date string, and for category', + 'data `end` is based on the category serial numbers.' + ].join(' ') + }, + size: { + valType: 'any', // for date axes + role: 'style', + editType: 'calc', + description: [ + 'Sets the size of each', axLetter, 'axis bin.', + 'Default behavior: If `nbins' + axLetter + '` is 0 or omitted,', + 'we choose a nice round bin size such that the number of bins', + 'is about the same as the typical number of samples in each bin.', + 'If `nbins' + axLetter + '` is provided, we choose a nice round', + 'bin size giving no more than that many bins.', + 'For date data, use milliseconds or *M* for months, as in', + '`axis.dtick`. For category data, the number of categories to', + 'bin together (always defaults to 1).', + (match ? ( + 'If multiple non-overlaying histograms share a subplot, ' + + 'the first explicit `size` is used and all others discarded. ' + + 'If no `size` is provided,the sample data from all traces ' + + 'is combined to determine `size` as described above.' + ) : '') + ].join(' ') + }, + editType: 'calc' + }; +}; diff --git a/src/traces/histogram/bin_defaults.js b/src/traces/histogram/bin_defaults.js deleted file mode 100644 index 77259579edd..00000000000 --- a/src/traces/histogram/bin_defaults.js +++ /dev/null @@ -1,32 +0,0 @@ -/** -* Copyright 2012-2018, Plotly, Inc. -* All rights reserved. -* -* This source code is licensed under the MIT license found in the -* LICENSE file in the root directory of this source tree. -*/ - - -'use strict'; - - -module.exports = function handleBinDefaults(traceIn, traceOut, coerce, binDirections) { - coerce('histnorm'); - - binDirections.forEach(function(binDirection) { - /* - * Because date axes have string values for start and end, - * and string options for size, we cannot validate these attributes - * now. We will do this during calc (immediately prior to binning) - * in ./clean_bins, and push the cleaned values back to _fullData. - */ - coerce(binDirection + 'bins.start'); - coerce(binDirection + 'bins.end'); - coerce(binDirection + 'bins.size'); - - var autobin = coerce('autobin' + binDirection); - if(autobin !== false) coerce('nbins' + binDirection); - }); - - return traceOut; -}; diff --git a/src/traces/histogram/calc.js b/src/traces/histogram/calc.js index 7d4659719c2..1a60c9206e9 100644 --- a/src/traces/histogram/calc.js +++ b/src/traces/histogram/calc.js @@ -18,8 +18,6 @@ var arraysToCalcdata = require('../bar/arrays_to_calcdata'); 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; var getBinSpanLabelRound = require('./bin_label_vals'); module.exports = function calc(gd, trace) { @@ -38,8 +36,6 @@ module.exports = function calc(gd, trace) { 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]; @@ -217,8 +213,26 @@ module.exports = function calc(gd, trace) { */ function calcAllAutoBins(gd, trace, pa, mainData, _overlayEdgeCase) { var binAttr = mainData + 'bins'; - var isOverlay = gd._fullLayout.barmode === 'overlay'; - var i, tracei, calendar, firstManual, pos0; + var fullLayout = gd._fullLayout; + var isOverlay = fullLayout.barmode === 'overlay'; + var i, traces, tracei, calendar, pos0, autoVals, cumulativeSpec; + + var cleanBound = (pa.type === 'date') ? + function(v) { return (v || v === 0) ? Lib.cleanDate(v, null, pa.calendar) : null; } : + function(v) { return isNumeric(v) ? Number(v) : null; }; + + function setBound(attr, bins, newBins) { + if(bins[attr + 'Found']) { + bins[attr] = cleanBound(bins[attr]); + if(bins[attr] === null) bins[attr] = newBins[attr]; + } + else { + autoVals[attr] = bins[attr] = newBins[attr]; + Lib.nestedProperty(traces[0], binAttr + '.' + attr).set(newBins[attr]); + } + } + + var binOpts = fullLayout._histogramBinOpts[trace._groupName]; // 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 @@ -226,121 +240,133 @@ function calcAllAutoBins(gd, trace, pa, mainData, _overlayEdgeCase) { delete trace._autoBinFinished; } else { - // must be the first trace in the group - do the autobinning on them all - - // find all grouped traces - in overlay mode each trace is independent - var traceGroup = isOverlay ? [trace] : 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 + traces = binOpts.traces; + var sizeFound = binOpts.sizeFound; + var allPos = []; + autoVals = traces[0]._autoBin = {}; + // Note: we're including `legendonly` traces here for autobin purposes, + // so that showing & hiding from the legend won't affect bins. + // But this complicates things a bit since those traces don't `calc`, + // hence `isFirstVisible`. + var isFirstVisible = true; + for(i = 0; i < traces.length; i++) { + tracei = traces[i]; 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); - - // Edge case: single-valued histogram overlaying others - // Use them all together to calculate the bin size for the single-valued one - if(isOverlay && binSpec._dataSpan === 0 && 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); + allPos = allPos.concat(pos0); + delete tracei._autoBinFinished; + if(trace.visible === true) { + if(isFirstVisible) { + isFirstVisible = false; } - - // 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); - } + else { + delete tracei._autoBin; + tracei._autoBinFinished = 1; } + } + } + calendar = traces[0][mainData + 'calendar']; + var newBinSpec = Axes.autoBin( + allPos, pa, binOpts.nbins, false, calendar, sizeFound && binOpts.size); + + // Edge case: single-valued histogram overlaying others + // Use them all together to calculate the bin size for the single-valued one + if(isOverlay && newBinSpec._dataSpan === 0 && 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 [newBinSpec, pos0, true]; + + newBinSpec = handleSingleValueOverlays(gd, trace, pa, mainData, binAttr); + } - // 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); + // adjust for CDF edge cases + cumulativeSpec = tracei.cumulative; + if(cumulativeSpec.enabled && (cumulativeSpec.currentbin !== 'include')) { + if(cumulativeSpec.direction === 'decreasing') { + newBinSpec.start = pa.c2r(Axes.tickIncrement( + pa.r2c(newBinSpec.start, 0, calendar), + newBinSpec.size, true, calendar + )); } - 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. - // 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), - end: pa.r2c(binSpec.end, 0, calendar) - }; + else { + newBinSpec.end = pa.c2r(Axes.tickIncrement( + pa.r2c(newBinSpec.end, 0, calendar), + newBinSpec.size, false, 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) tracei._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); + binOpts.size = newBinSpec.size; + if(!sizeFound) { + autoVals.size = newBinSpec.size; + Lib.nestedProperty(traces[0], binAttr + '.size').set(newBinSpec.size); } - // 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']; + setBound('start', binOpts, newBinSpec); + setBound('end', binOpts, newBinSpec); + } - tracei._input[binAttr] = tracei[binAttr] = { - start: pa.c2r(minStart, 0, calendar), - end: pa.c2r(maxEnd, 0, calendar), - size: minSize - }; + pos0 = trace._pos0; + delete trace._pos0; - // 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]; + // Each trace can specify its own start/end, or if omitted + // we ensure they're beyond the bounds of this trace's data, + // and we need to make sure start is aligned with the main start + var traceInputBins = trace._input[binAttr] || {}; + var traceBinOptsCalc = Lib.extendFlat({}, binOpts); + var mainStart = binOpts.start; + var startIn = pa.r2l(traceInputBins.start); + var hasStart = startIn !== undefined; + if((binOpts.startFound || hasStart) && startIn !== pa.r2l(mainStart)) { + // We have an explicit start to reconcile across traces + // if this trace has an explicit start, shift it down to a bin edge + // if another trace had an explicit start, shift it down to a + // bin edge past our data + var traceStart = hasStart ? + startIn : + Lib.aggNums(Math.min, null, pos0); + + var dummyAx = { + type: pa.type === 'category' ? 'linear' : pa.type, + r2l: pa.r2l, + dtick: binOpts.size, + tick0: mainStart, + calendar: calendar, + range: ([traceStart, Axes.tickIncrement(traceStart, binOpts.size, false, calendar)]).map(pa.l2r) + }; + var newStart = Axes.tickFirst(dummyAx); + if(newStart > pa.r2l(traceStart)) { + newStart = Axes.tickIncrement(newStart, binOpts.size, true, calendar); } + traceBinOptsCalc.start = pa.l2r(newStart); + if(!hasStart) Lib.nestedProperty(trace, binAttr + '.start').set(traceBinOptsCalc.start); } - pos0 = trace._pos0; - delete trace._pos0; + var mainEnd = binOpts.end; + var endIn = pa.r2l(traceInputBins.end); + var hasEnd = endIn !== undefined; + if((binOpts.endFound || hasEnd) && endIn !== pa.r2l(mainEnd)) { + // Reconciling an explicit end is easier, as it doesn't need to + // match bin edges + var traceEnd = hasEnd ? + endIn : + Lib.aggNums(Math.max, null, pos0); + + traceBinOptsCalc.end = pa.l2r(traceEnd); + if(!hasEnd) Lib.nestedProperty(trace, binAttr + '.start').set(traceBinOptsCalc.end); + } - return [trace[binAttr], pos0]; + // Backward compatibility for one-time autobinning. + // autobin: true is handled in cleanData, but autobin: false + // needs to be here where we have determined the values. + var autoBinAttr = 'autobin' + mainData; + if(trace._input[autoBinAttr] === false) { + trace._input[binAttr] = Lib.extendFlat({}, trace[binAttr] || {}); + delete trace._input[autoBinAttr]; + delete trace[autoBinAttr]; + } + + return [traceBinOptsCalc, pos0]; } /* @@ -449,25 +475,6 @@ function getConnectedHistograms(gd, trace) { } -/* - * 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; diff --git a/src/traces/histogram/clean_bins.js b/src/traces/histogram/clean_bins.js deleted file mode 100644 index dc322d7401d..00000000000 --- a/src/traces/histogram/clean_bins.js +++ /dev/null @@ -1,78 +0,0 @@ -/** -* Copyright 2012-2018, Plotly, Inc. -* All rights reserved. -* -* This source code is licensed under the MIT license found in the -* LICENSE file in the root directory of this source tree. -*/ - - -'use strict'; -var isNumeric = require('fast-isnumeric'); -var cleanDate = require('../../lib').cleanDate; -var constants = require('../../constants/numerical'); -var ONEDAY = constants.ONEDAY; -var BADNUM = constants.BADNUM; - -/* - * cleanBins: validate attributes autobin[xy] and [xy]bins.(start, end, size) - * Mutates trace so all these attributes are valid. - * - * Normally this kind of thing would happen during supplyDefaults, but - * in this case we need to know the axis type, and axis type isn't set until - * after trace supplyDefaults are completed. So this gets called during the - * calc step, when data are inserted into bins. - */ -module.exports = function cleanBins(trace, ax, binDirection) { - var axType = ax.type, - binAttr = binDirection + 'bins', - bins = trace[binAttr]; - - if(!bins) bins = trace[binAttr] = {}; - - var cleanBound = (axType === 'date') ? - function(v) { return (v || v === 0) ? cleanDate(v, BADNUM, bins.calendar) : null; } : - function(v) { return isNumeric(v) ? Number(v) : null; }; - - bins.start = cleanBound(bins.start); - bins.end = cleanBound(bins.end); - - // logic for bin size is very similar to dtick (cartesian/tick_value_defaults) - // but without the extra string options for log axes - // ie the only strings we accept are M for months - var sizeDflt = (axType === 'date') ? ONEDAY : 1, - binSize = bins.size; - - if(isNumeric(binSize)) { - bins.size = (binSize > 0) ? Number(binSize) : sizeDflt; - } - else if(typeof binSize !== 'string') { - bins.size = sizeDflt; - } - else { - // date special case: "M" gives bins every (integer) n months - var prefix = binSize.charAt(0), - sizeNum = binSize.substr(1); - - sizeNum = isNumeric(sizeNum) ? Number(sizeNum) : 0; - if((sizeNum <= 0) || !( - axType === 'date' && prefix === 'M' && sizeNum === Math.round(sizeNum) - )) { - bins.size = sizeDflt; - } - } - - var autoBinAttr = 'autobin' + binDirection; - - if(typeof trace[autoBinAttr] !== 'boolean') { - trace[autoBinAttr] = trace._fullInput[autoBinAttr] = trace._input[autoBinAttr] = !( - (bins.start || bins.start === 0) && - (bins.end || bins.end === 0) - ); - } - - if(!trace[autoBinAttr]) { - delete trace['nbins' + binDirection]; - delete trace._fullInput['nbins' + binDirection]; - } -}; diff --git a/src/traces/histogram/cross_trace_defaults.js b/src/traces/histogram/cross_trace_defaults.js new file mode 100644 index 00000000000..4bffacb4d69 --- /dev/null +++ b/src/traces/histogram/cross_trace_defaults.js @@ -0,0 +1,112 @@ +/** +* Copyright 2012-2018, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Lib = require('../../lib'); +var nestedProperty = Lib.nestedProperty; + +var attributes = require('./attributes'); + +var BINATTRS = { + x: [ + {aStr: 'xbins.start', name: 'start'}, + {aStr: 'xbins.end', name: 'end'}, + {aStr: 'xbins.size', name: 'size'}, + {aStr: 'nbinsx', name: 'nbins'} + ], + y: [ + {aStr: 'ybins.start', name: 'start'}, + {aStr: 'ybins.end', name: 'end'}, + {aStr: 'ybins.size', name: 'size'}, + {aStr: 'nbinsy', name: 'nbins'} + ] +}; + +// handle bin attrs and relink auto-determined values so fullData is complete +module.exports = function crossTraceDefaults(fullData, fullLayout) { + var allBinOpts = fullLayout._histogramBinOpts = {}; + var isOverlay = fullLayout.barmode === 'overlay'; + var i, j, traceOut, traceIn, binDirection, group, binOpts; + + function coerce(attr) { + return Lib.coerce(traceOut._input, traceOut, attributes, attr); + } + + for(i = 0; i < fullData.length; i++) { + traceOut = fullData[i]; + if(traceOut.type !== 'histogram') continue; + + // TODO: this shouldn't be relinked as it's only used within calc + // https://github.com/plotly/plotly.js/issues/749 + delete traceOut._autoBinFinished; + + binDirection = traceOut.orientation === 'v' ? 'x' : 'y'; + // in overlay mode make a separate group for each trace + // otherwise collect all traces of the same subplot & orientation + group = isOverlay ? traceOut.uid : (traceOut.xaxis + traceOut.yaxis + binDirection); + traceOut._groupName = group; + + binOpts = allBinOpts[group]; + + if(binOpts) { + binOpts.traces.push(traceOut); + } + else { + binOpts = allBinOpts[group] = { + traces: [traceOut], + direction: binDirection + }; + } + } + + for(group in allBinOpts) { + binOpts = allBinOpts[group]; + binDirection = binOpts.direction; + var attrs = BINATTRS[binDirection]; + for(j = 0; j < attrs.length; j++) { + var attrSpec = attrs[j]; + var attr = attrSpec.name; + + // nbins(x|y) is moot if we have a size. This depends on + // nbins coming after size in binAttrs. + if(attr === 'nbins' && binOpts.sizeFound) continue; + + var aStr = attrSpec.aStr; + for(i = 0; i < binOpts.traces.length; i++) { + traceOut = binOpts.traces[i]; + traceIn = traceOut._input; + if(nestedProperty(traceIn, aStr).get() !== undefined) { + binOpts[attr] = coerce(aStr); + binOpts[attr + 'Found'] = true; + break; + } + var autoVals = traceOut._autoBin; + if(autoVals && autoVals[attr]) { + // if this is the *first* autoval + nestedProperty(traceOut, aStr).set(autoVals[attr]); + } + } + // start and end we need to coerce anyway, after having collected the + // first of each into binOpts, in case a trace wants to restrict its + // data to a certain range + if(attr === 'start' || attr === 'end') { + for(; i < binOpts.traces.length; i++) { + traceOut = binOpts.traces[i]; + coerce(aStr, (traceOut._autoBin || {})[attr]); + } + } + + if(attr === 'nbins' && !binOpts.sizeFound && !binOpts.nbinsFound) { + traceOut = binOpts.traces[0]; + binOpts[attr] = coerce(aStr); + } + } + } +}; diff --git a/src/traces/histogram/defaults.js b/src/traces/histogram/defaults.js index b56b451311a..23ef933ba18 100644 --- a/src/traces/histogram/defaults.js +++ b/src/traces/histogram/defaults.js @@ -13,7 +13,6 @@ var Registry = require('../../registry'); var Lib = require('../../lib'); var Color = require('../../components/color'); -var handleBinDefaults = require('./bin_defaults'); var handleStyleDefaults = require('../bar/style_defaults'); var attributes = require('./attributes'); @@ -51,8 +50,11 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout var hasAggregationData = traceOut[aggLetter]; if(hasAggregationData) coerce('histfunc'); + coerce('histnorm'); - handleBinDefaults(traceIn, traceOut, coerce, [sampleLetter]); + // Note: bin defaults are now handled in Histogram.crossTraceDefaults + // autobin(x|y) are only included here to appease Plotly.validate + coerce('autobin' + sampleLetter); handleStyleDefaults(traceIn, traceOut, coerce, defaultColor, layout); diff --git a/src/traces/histogram/index.js b/src/traces/histogram/index.js index 25fc27a227e..b157d73f4be 100644 --- a/src/traces/histogram/index.js +++ b/src/traces/histogram/index.js @@ -28,6 +28,7 @@ var Histogram = {}; Histogram.attributes = require('./attributes'); Histogram.layoutAttributes = require('../bar/layout_attributes'); Histogram.supplyDefaults = require('./defaults'); +Histogram.crossTraceDefaults = require('./cross_trace_defaults'); Histogram.supplyLayoutDefaults = require('../bar/layout_defaults'); Histogram.calc = require('./calc'); Histogram.crossTraceCalc = require('../bar/cross_trace_calc').crossTraceCalc; diff --git a/src/traces/histogram2d/attributes.js b/src/traces/histogram2d/attributes.js index 7d166f3daf1..eba5151eb0b 100644 --- a/src/traces/histogram2d/attributes.js +++ b/src/traces/histogram2d/attributes.js @@ -9,6 +9,7 @@ 'use strict'; var histogramAttrs = require('../histogram/attributes'); +var makeBinAttrs = require('../histogram/bin_attributes'); var heatmapAttrs = require('../heatmap/attributes'); var colorscaleAttrs = require('../../components/colorscale/attributes'); var colorbarAttrs = require('../../components/colorbar/attributes'); @@ -36,12 +37,12 @@ module.exports = extendFlat( histnorm: histogramAttrs.histnorm, histfunc: histogramAttrs.histfunc, - autobinx: histogramAttrs.autobinx, nbinsx: histogramAttrs.nbinsx, - xbins: histogramAttrs.xbins, - autobiny: histogramAttrs.autobiny, + xbins: makeBinAttrs('x'), nbinsy: histogramAttrs.nbinsy, - ybins: histogramAttrs.ybins, + ybins: makeBinAttrs('y'), + autobinx: histogramAttrs.autobinx, + autobiny: histogramAttrs.autobiny, xgap: heatmapAttrs.xgap, ygap: heatmapAttrs.ygap, diff --git a/src/traces/histogram2d/calc.js b/src/traces/histogram2d/calc.js index bc3c91294fe..7a221a8eb91 100644 --- a/src/traces/histogram2d/calc.js +++ b/src/traces/histogram2d/calc.js @@ -15,7 +15,6 @@ var Axes = require('../../plots/cartesian/axes'); var binFunctions = require('../histogram/bin_functions'); var normFunctions = require('../histogram/norm_functions'); var doAvg = require('../histogram/average'); -var cleanBins = require('../histogram/clean_bins'); var getBinSpanLabelRound = require('../histogram/bin_label_vals'); @@ -38,8 +37,8 @@ module.exports = function calc(gd, trace) { if(y.length > serieslen) y.splice(serieslen, y.length - serieslen); // calculate the bins - cleanAndAutobin(trace, 'x', x, xa, xr2c, xc2r, xcalendar); - cleanAndAutobin(trace, 'y', y, ya, yr2c, yc2r, ycalendar); + doAutoBin(trace, 'x', x, xa, xr2c, xc2r, xcalendar); + doAutoBin(trace, 'y', y, ya, yr2c, yc2r, ycalendar); // make the empty bin array & scale the map var z = []; @@ -182,31 +181,49 @@ module.exports = function calc(gd, trace) { }; }; -function cleanAndAutobin(trace, axLetter, data, ax, r2c, c2r, calendar) { - var binSpecAttr = axLetter + 'bins'; - var autoBinAttr = 'autobin' + axLetter; - var binSpec = trace[binSpecAttr]; +function doAutoBin(trace, axLetter, data, ax, r2c, c2r, calendar) { + var binAttr = axLetter + 'bins'; + var binSpec = trace[binAttr]; + if(!binSpec) binSpec = trace[binAttr] = {}; + var inputBinSpec = trace._input[binAttr] || {}; + var autoBin = trace._autoBin = {}; + + // clear out any previously added autobin info + if(!inputBinSpec.size) delete binSpec.size; + if(inputBinSpec.start === undefined) delete binSpec.start; + if(inputBinSpec.end === undefined) delete binSpec.end; - cleanBins(trace, ax, axLetter); + var autoSize = !binSpec.size; + var autoStart = binSpec.start === undefined; + var autoEnd = binSpec.end === undefined; - if(trace[autoBinAttr] || !binSpec || binSpec.start === null || binSpec.end === null) { - binSpec = Axes.autoBin(data, ax, trace['nbins' + axLetter], '2d', calendar); + if(autoSize || autoStart || autoEnd) { + var newBinSpec = Axes.autoBin(data, ax, trace['nbins' + axLetter], '2d', calendar, binSpec.size); if(trace.type === 'histogram2dcontour') { - // the "true" last argument reverses the tick direction (which we can't + // the "true" 2nd argument reverses the tick direction (which we can't // just do with a minus sign because of month bins) - binSpec.start = c2r(Axes.tickIncrement( - r2c(binSpec.start), binSpec.size, true, calendar)); - binSpec.end = c2r(Axes.tickIncrement( - r2c(binSpec.end), binSpec.size, false, calendar)); + if(autoStart) { + newBinSpec.start = c2r(Axes.tickIncrement( + r2c(newBinSpec.start), newBinSpec.size, true, calendar)); + } + if(autoEnd) { + newBinSpec.end = c2r(Axes.tickIncrement( + r2c(newBinSpec.end), newBinSpec.size, false, calendar)); + } } + if(autoSize) binSpec.size = autoBin.size = newBinSpec.size; + if(autoStart) binSpec.start = autoBin.start = newBinSpec.start; + if(autoEnd) binSpec.end = autoBin.end = newBinSpec.end; + } - // copy bin info back to the source data. - trace._input[binSpecAttr] = trace[binSpecAttr] = 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]; + // Backward compatibility for one-time autobinning. + // autobin: true is handled in cleanData, but autobin: false + // needs to be here where we have determined the values. + var autoBinAttr = 'autobin' + axLetter; + if(trace._input[autoBinAttr] === false) { + trace._input[binAttr] = Lib.extendFlat({}, binSpec); + delete trace._input[autoBinAttr]; + delete trace[autoBinAttr]; } } diff --git a/src/traces/histogram2d/cross_trace_defaults.js b/src/traces/histogram2d/cross_trace_defaults.js new file mode 100644 index 00000000000..a32db267f94 --- /dev/null +++ b/src/traces/histogram2d/cross_trace_defaults.js @@ -0,0 +1,93 @@ +/** +* Copyright 2012-2018, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + + +var isNumeric = require('fast-isnumeric'); + +var BADNUM = require('../../constants/numerical').BADNUM; +var axisIds = require('../../plots/cartesian/axis_ids'); +var Lib = require('../../lib'); + +var attributes = require('./attributes'); + +var BINDIRECTIONS = ['x', 'y']; + +// Handle bin attrs and relink auto-determined values so fullData is complete +// does not have cross-trace coupling, but moved out here so we have axis types +// and relinked trace._autoBin +module.exports = function crossTraceDefaults(fullData, fullLayout) { + var i, j, traceOut, binDirection; + + function coerce(attr) { + return Lib.coerce(traceOut._input, traceOut, attributes, attr); + } + + for(i = 0; i < fullData.length; i++) { + traceOut = fullData[i]; + var type = traceOut.type; + if(type !== 'histogram2d' && type !== 'histogram2dcontour') continue; + + for(j = 0; j < BINDIRECTIONS.length; j++) { + binDirection = BINDIRECTIONS[j]; + var binAttr = binDirection + 'bins'; + var autoBins = (traceOut._autoBin || {})[binDirection] || {}; + coerce(binAttr + '.start', autoBins.start); + coerce(binAttr + '.end', autoBins.end); + coerce(binAttr + '.size', autoBins.size); + + cleanBins(traceOut, binDirection, fullLayout, autoBins); + + if(!(traceOut[binAttr] || {}).size) coerce('nbins' + binDirection); + } + } +}; + +function cleanBins(trace, binDirection, fullLayout, autoBins) { + var ax = fullLayout[axisIds.id2name(trace[binDirection + 'axis'])]; + var axType = ax.type; + var binAttr = binDirection + 'bins'; + var bins = trace[binAttr]; + var calendar = trace[binDirection + 'calendar']; + + if(!bins) bins = trace[binAttr] = {}; + + var cleanBound = (axType === 'date') ? + function(v, dflt) { return (v || v === 0) ? Lib.cleanDate(v, BADNUM, calendar) : dflt; } : + function(v, dflt) { return isNumeric(v) ? Number(v) : dflt; }; + + bins.start = cleanBound(bins.start, autoBins.start); + bins.end = cleanBound(bins.end, autoBins.end); + + // logic for bin size is very similar to dtick (cartesian/tick_value_defaults) + // but without the extra string options for log axes + // ie the only strings we accept are M for months + var sizeDflt = autoBins.size; + var binSize = bins.size; + + if(isNumeric(binSize)) { + bins.size = (binSize > 0) ? Number(binSize) : sizeDflt; + } + else if(typeof binSize !== 'string') { + bins.size = sizeDflt; + } + else { + // date special case: "M" gives bins every (integer) n months + var prefix = binSize.charAt(0); + var sizeNum = binSize.substr(1); + + sizeNum = isNumeric(sizeNum) ? Number(sizeNum) : 0; + if((sizeNum <= 0) || !( + axType === 'date' && prefix === 'M' && sizeNum === Math.round(sizeNum) + )) { + bins.size = sizeDflt; + } + } +} diff --git a/src/traces/histogram2d/index.js b/src/traces/histogram2d/index.js index af2549a9c18..4423cfb0945 100644 --- a/src/traces/histogram2d/index.js +++ b/src/traces/histogram2d/index.js @@ -13,6 +13,7 @@ var Histogram2D = {}; Histogram2D.attributes = require('./attributes'); Histogram2D.supplyDefaults = require('./defaults'); +Histogram2D.crossTraceDefaults = require('./cross_trace_defaults'); Histogram2D.calc = require('../heatmap/calc'); Histogram2D.plot = require('../heatmap/plot'); Histogram2D.layerName = 'heatmaplayer'; diff --git a/src/traces/histogram2d/sample_defaults.js b/src/traces/histogram2d/sample_defaults.js index 80575051d26..aca6acf6595 100644 --- a/src/traces/histogram2d/sample_defaults.js +++ b/src/traces/histogram2d/sample_defaults.js @@ -10,8 +10,6 @@ 'use strict'; var Registry = require('../../registry'); -var handleBinDefaults = require('../histogram/bin_defaults'); - module.exports = function handleSampleDefaults(traceIn, traceOut, coerce, layout) { var x = coerce('x'); @@ -34,7 +32,10 @@ module.exports = function handleSampleDefaults(traceIn, traceOut, coerce, layout var hasAggregationData = coerce('z') || coerce('marker.color'); if(hasAggregationData) coerce('histfunc'); + coerce('histnorm'); - var binDirections = ['x', 'y']; - handleBinDefaults(traceIn, traceOut, coerce, binDirections); + // Note: bin defaults are now handled in Histogram2D.crossTraceDefaults + // autobin(x|y) are only included here to appease Plotly.validate + coerce('autobinx'); + coerce('autobiny'); }; diff --git a/src/traces/histogram2dcontour/attributes.js b/src/traces/histogram2dcontour/attributes.js index 048a0c481d4..ee60da32e57 100644 --- a/src/traces/histogram2dcontour/attributes.js +++ b/src/traces/histogram2dcontour/attributes.js @@ -23,12 +23,12 @@ module.exports = extendFlat({ histnorm: histogram2dAttrs.histnorm, histfunc: histogram2dAttrs.histfunc, - autobinx: histogram2dAttrs.autobinx, nbinsx: histogram2dAttrs.nbinsx, xbins: histogram2dAttrs.xbins, - autobiny: histogram2dAttrs.autobiny, nbinsy: histogram2dAttrs.nbinsy, ybins: histogram2dAttrs.ybins, + autobinx: histogram2dAttrs.autobinx, + autobiny: histogram2dAttrs.autobiny, autocontour: contourAttrs.autocontour, ncontours: contourAttrs.ncontours, diff --git a/src/traces/histogram2dcontour/index.js b/src/traces/histogram2dcontour/index.js index c16206dff5e..7e8400d7499 100644 --- a/src/traces/histogram2dcontour/index.js +++ b/src/traces/histogram2dcontour/index.js @@ -13,6 +13,7 @@ var Histogram2dContour = {}; Histogram2dContour.attributes = require('./attributes'); Histogram2dContour.supplyDefaults = require('./defaults'); +Histogram2dContour.crossTraceDefaults = require('../histogram2d/cross_trace_defaults'); Histogram2dContour.calc = require('../contour/calc'); Histogram2dContour.plot = require('../contour/plot').plot; Histogram2dContour.layerName = 'contourlayer'; diff --git a/src/traces/scatter/clean_data.js b/src/traces/scatter/cross_trace_defaults.js similarity index 94% rename from src/traces/scatter/clean_data.js rename to src/traces/scatter/cross_trace_defaults.js index b72ab26eb1a..7f079158165 100644 --- a/src/traces/scatter/clean_data.js +++ b/src/traces/scatter/cross_trace_defaults.js @@ -11,7 +11,7 @@ // remove opacity for any trace that has a fill or is filled to -module.exports = function cleanData(fullData) { +module.exports = function crossTraceDefaults(fullData) { for(var i = 0; i < fullData.length; i++) { var tracei = fullData[i]; if(tracei.type !== 'scatter') continue; diff --git a/src/traces/scatter/index.js b/src/traces/scatter/index.js index 133b54ae32e..82736acb321 100644 --- a/src/traces/scatter/index.js +++ b/src/traces/scatter/index.js @@ -19,7 +19,7 @@ Scatter.isBubble = subtypes.isBubble; Scatter.attributes = require('./attributes'); Scatter.supplyDefaults = require('./defaults'); -Scatter.cleanData = require('./clean_data'); +Scatter.crossTraceDefaults = require('./cross_trace_defaults'); Scatter.calc = require('./calc').calc; Scatter.crossTraceCalc = require('./cross_trace_calc'); Scatter.arraysToCalcdata = require('./arrays_to_calcdata'); diff --git a/src/traces/scattergl/index.js b/src/traces/scattergl/index.js index 14f8b27d021..99657b2c68a 100644 --- a/src/traces/scattergl/index.js +++ b/src/traces/scattergl/index.js @@ -952,7 +952,7 @@ module.exports = { attributes: require('./attributes'), supplyDefaults: require('./defaults'), - cleanData: require('../scatter/clean_data'), + crossTraceDefaults: require('../scatter/cross_trace_defaults'), colorbar: require('../scatter/marker_colorbar'), calc: calc, plot: plot, diff --git a/test/image/baselines/hist_multi.png b/test/image/baselines/hist_multi.png new file mode 100644 index 00000000000..df9598daf23 Binary files /dev/null and b/test/image/baselines/hist_multi.png differ diff --git a/test/image/mocks/hist_multi.json b/test/image/mocks/hist_multi.json new file mode 100644 index 00000000000..05ef17e5ad3 --- /dev/null +++ b/test/image/mocks/hist_multi.json @@ -0,0 +1,22 @@ +{ + "data":[{ + "x": [1, 1, 1, 2, 2], + "type": "histogram" + }, { + "x": [20, 20, 21, 21], + "type": "histogram" + }, { + "x": [1, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 7], + "type": "histogram", + "xaxis": "x2" + }, { + "x": [6, 6.1, 6.2], + "type": "histogram", + "xaxis": "x2" + }], + "layout": { + "height": 400, "width": 500, + "barmode": "stack", + "grid": {"rows": 1, "columns": 2} + } +} diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index bfc15effcc0..e3a2680b343 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -1220,7 +1220,7 @@ describe('Test axes', function() { axOut = {}; mockSupplyDefaults(axIn, axOut, 'log'); // tick0 gets ignored for D - expect(axOut.tick0).toBe(0); + expect(axOut.tick0).toBeUndefined(v); expect(axOut.dtick).toBe(v); }); diff --git a/test/jasmine/tests/bar_test.js b/test/jasmine/tests/bar_test.js index e7b3f395a1e..609231fffda 100644 --- a/test/jasmine/tests/bar_test.js +++ b/test/jasmine/tests/bar_test.js @@ -1361,6 +1361,7 @@ describe('bar visibility toggling:', function() { spyOn(gd._fullData[0]._module, 'crossTraceCalc').and.callThrough(); _assert('base', [0.5, 3.5], [-2.222, 2.222], 0); + expect(gd._fullLayout.legend.traceorder).toBe('normal'); return Plotly.restyle(gd, 'visible', false, [1]); }) .then(function() { @@ -1369,6 +1370,11 @@ describe('bar visibility toggling:', function() { }) .then(function() { _assert('both invisible', [0.5, 3.5], [0, 2.105], 0); + return Plotly.restyle(gd, 'visible', 'legendonly'); + }) + .then(function() { + _assert('both legendonly', [0.5, 3.5], [0, 2.105], 0); + expect(gd._fullLayout.legend.traceorder).toBe('normal'); return Plotly.restyle(gd, 'visible', true, [1]); }) .then(function() { @@ -1391,6 +1397,7 @@ describe('bar visibility toggling:', function() { spyOn(gd._fullData[0]._module, 'crossTraceCalc').and.callThrough(); _assert('base', [0.5, 3.5], [0, 5.263], 0); + expect(gd._fullLayout.legend.traceorder).toBe('reversed'); return Plotly.restyle(gd, 'visible', false, [1]); }) .then(function() { @@ -1399,6 +1406,11 @@ describe('bar visibility toggling:', function() { }) .then(function() { _assert('both invisible', [0.5, 3.5], [0, 2.105], 0); + return Plotly.restyle(gd, 'visible', 'legendonly'); + }) + .then(function() { + _assert('both legendonly', [0.5, 3.5], [0, 2.105], 0); + expect(gd._fullLayout.legend.traceorder).toBe('reversed'); return Plotly.restyle(gd, 'visible', true, [1]); }) .then(function() { @@ -1411,6 +1423,37 @@ describe('bar visibility toggling:', function() { .catch(failTest) .then(done); }); + + it('gets the right legend traceorder if all bars are visible: false', function(done) { + function _assert(traceorder, yRange, legendCount) { + expect(gd._fullLayout.legend.traceorder).toBe(traceorder); + expect(gd._fullLayout.yaxis.range).toBeCloseToArray(yRange, 2); + expect(d3.select(gd).selectAll('.legend .traces').size()).toBe(legendCount); + } + Plotly.newPlot(gd, [ + {type: 'bar', y: [1, 2, 3]}, + {type: 'bar', y: [3, 2, 1]}, + {y: [2, 3, 2]}, + {y: [3, 2, 3]} + ], { + barmode: 'stack', width: 400, height: 400 + }) + .then(function() { + _assert('reversed', [0, 4.211], 4); + + return Plotly.restyle(gd, {visible: false}, [0, 1]); + }) + .then(function() { + _assert('normal', [1.922, 3.077], 2); + + return Plotly.restyle(gd, {visible: 'legendonly'}, [0, 1]); + }) + .then(function() { + _assert('reversed', [1.922, 3.077], 4); + }) + .catch(failTest) + .then(done); + }); }); describe('bar hover', function() { diff --git a/test/jasmine/tests/histogram2d_test.js b/test/jasmine/tests/histogram2d_test.js index 3950324aeb5..7893e18c767 100644 --- a/test/jasmine/tests/histogram2d_test.js +++ b/test/jasmine/tests/histogram2d_test.js @@ -182,6 +182,16 @@ describe('Test histogram2d', function() { .then(done); }); + function _assert(xBinsFull, yBinsFull, xBins, yBins) { + expect(gd._fullData[0].xbins).toEqual(xBinsFull); + expect(gd._fullData[0].ybins).toEqual(yBinsFull); + expect(gd._fullData[0].autobinx).toBeUndefined(); + expect(gd._fullData[0].autobiny).toBeUndefined(); + expect(gd.data[0].xbins).toEqual(xBins); + expect(gd.data[0].ybins).toEqual(yBins); + expect(gd.data[0].autobinx).toBeUndefined(); + expect(gd.data[0].autobiny).toBeUndefined(); + } it('handles autobin correctly on restyles', function() { var x1 = [ @@ -191,65 +201,64 @@ 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, _dataSpan: 3}); - expect(gd._fullData[0].ybins).toEqual({start: 0.5, end: 4.5, size: 1, _dataSpan: 3}); - expect(gd._fullData[0].autobinx).toBe(true); - expect(gd._fullData[0].autobiny).toBe(true); + _assert( + {start: 0.5, end: 4.5, size: 1}, + {start: 0.5, end: 4.5, size: 1}, + undefined, undefined); // 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, _dataSpan: 3}); - expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 5.5, size: 2, _dataSpan: 3}); - expect(gd._fullData[0].autobinx).toBe(true); - expect(gd._fullData[0].autobiny).toBe(true); + _assert( + {start: -0.5, end: 5.5, size: 2}, + {start: -0.5, end: 5.5, size: 2}, + undefined, undefined); // 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, _dataSpan: 30}); - expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 59.5, size: 20, _dataSpan: 30}); - expect(gd._fullData[0].autobinx).toBe(true); - expect(gd._fullData[0].autobiny).toBe(true); + _assert( + {start: -0.5, end: 59.5, size: 20}, + {start: -0.5, end: 59.5, size: 20}, + undefined, undefined); // explicit changes to bin settings Plotly.restyle(gd, 'xbins.start', 12); - expect(gd._fullData[0].xbins).toEqual({start: 12, end: 59.5, size: 20, _dataSpan: 30}); - expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 59.5, size: 20, _dataSpan: 30}); - expect(gd._fullData[0].autobinx).toBe(false); - expect(gd._fullData[0].autobiny).toBe(true); + _assert( + {start: 12, end: 59.5, size: 20}, + {start: -0.5, end: 59.5, size: 20}, + {start: 12}, undefined); Plotly.restyle(gd, {'ybins.end': 12, 'ybins.size': 3}); - expect(gd._fullData[0].xbins).toEqual({start: 12, end: 59.5, size: 20, _dataSpan: 30}); - expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 12, size: 3, _dataSpan: 30}); - expect(gd._fullData[0].autobinx).toBe(false); - expect(gd._fullData[0].autobiny).toBe(false); + _assert( + {start: 12, end: 59.5, size: 20}, + // with the new autobin algo, start responds to autobin + {start: 8.5, end: 12, size: 3}, + {start: 12}, + {end: 12, size: 3}); // restart autobin Plotly.restyle(gd, {autobinx: true, autobiny: true}); - expect(gd._fullData[0].xbins).toEqual({start: -0.5, end: 59.5, size: 20, _dataSpan: 30}); - expect(gd._fullData[0].ybins).toEqual({start: -0.5, end: 59.5, size: 20, _dataSpan: 30}); - expect(gd._fullData[0].autobinx).toBe(true); - expect(gd._fullData[0].autobiny).toBe(true); + _assert( + {start: -0.5, end: 59.5, size: 20}, + {start: -0.5, end: 59.5, size: 20}, + undefined, undefined); }); it('respects explicit autobin: false as a one-time autobin', function() { + // patched in for backward compat, but there aren't really + // autobinx/autobiny attributes anymore var x1 = [ 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4]; var y1 = [ 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]; + var binSpec = {start: 0.5, end: 4.5, size: 1}; 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, _dataSpan: 3}); - expect(gd._fullData[0].ybins).toEqual({start: 0.5, end: 4.5, size: 1, _dataSpan: 3}); - expect(gd._fullData[0].autobinx).toBe(false); - expect(gd._fullData[0].autobiny).toBe(false); + _assert(binSpec, binSpec, binSpec, binSpec); // 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, _dataSpan: 3}); - expect(gd._fullData[0].ybins).toEqual({start: 0.5, end: 4.5, size: 1, _dataSpan: 3}); - expect(gd._fullData[0].autobinx).toBe(false); - expect(gd._fullData[0].autobiny).toBe(false); + _assert(binSpec, binSpec, binSpec, binSpec); }); }); diff --git a/test/jasmine/tests/histogram_test.js b/test/jasmine/tests/histogram_test.js index e39096a7c94..1d86448b260 100644 --- a/test/jasmine/tests/histogram_test.js +++ b/test/jasmine/tests/histogram_test.js @@ -187,10 +187,10 @@ describe('Test histogram', function() { describe('calc', function() { - function _calc(opts, extraTraces, layout) { + function _calc(opts, extraTraces, layout, prependExtras) { var base = { type: 'histogram' }; var trace = Lib.extendFlat({}, base, opts); - var gd = { data: [trace] }; + var gd = { data: prependExtras ? [] : [trace] }; if(layout) gd.layout = layout; @@ -200,8 +200,16 @@ describe('Test histogram', function() { }); } + if(prependExtras) gd.data.push(trace); + supplyAllDefaults(gd); - var fullTrace = gd._fullData[0]; + var fullTrace = gd._fullData[prependExtras ? gd._fullData.length - 1 : 0]; + + if(prependExtras) { + for(var i = 0; i < gd._fullData.length - 1; i++) { + calc(gd, gd._fullData[i]); + } + } var out = calc(gd, fullTrace); delete out[0].trace; @@ -408,8 +416,8 @@ describe('Test histogram', function() { ]); }); - function calcPositions(opts, extraTraces) { - return _calc(opts, extraTraces).map(function(v) { return v.p; }); + function calcPositions(opts, extraTraces, prepend) { + return _calc(opts, extraTraces, {}, prepend).map(function(v) { return v.p; }); } it('harmonizes autobins when all traces are autobinned', function() { @@ -420,25 +428,11 @@ describe('Test histogram', function() { 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. + expect(calcPositions(trace1, [trace2])).toEqual([1, 3, 5]); + expect(calcPositions(trace2, [trace1])).toEqual([5, 7]); + }); + it('autobins all data as one', function() { // all integers, so all autobins should get shifted to start 0.5 lower // than they otherwise would. var trace1 = {x: [1, 2, 3, 4]}; @@ -450,19 +444,21 @@ describe('Test histogram', function() { // {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); + // together bins match the wider trace + expect(calcPositions(trace1, [trace2])).toBeCloseToArray([2], 5); + expect(calcPositions(trace2, [trace1])).toEqual([-3, 2, 7]); + + // unless we add enough points to shrink the bins + expect(calcPositions(trace2, [trace1, trace1, trace1, trace1])) + .toBeCloseToArray([-1.5, 0.5, 2.5, 4.5, 6.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}}; + // size is preserved, and start is shifted to be compatible with trace2 + // (but we can't just use start from trace2 or it would cut off all our data!) expect(calcPositions(trace1, [trace2])).toBeCloseToArray([ 0.9, 1.3, 1.7, 2.1, 2.5, 2.9, 3.3, 3.7, 4.1 ], 5); @@ -470,20 +466,73 @@ describe('Test histogram', function() { 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}}; + var trace2 = {x: [5, 6, 7, 8], xbins: {start: 3.7, end: 15, size: 7}}; expect(calcPositions(trace1, [trace2])).toBeCloseToArray([ - 0.8, 2.55, 4.3 + 0.2, 7.2 ], 5); }); + it('ignores incompatible sizes, and harmonizes start values', function() { + var trace1 = {x: [1, 2, 3, 4], xbins: {start: 1.7, end: 3.5, size: 0.6}}; + var trace2 = {x: [5, 6, 7, 8], xbins: {start: 4.3, end: 7.1, size: 0.4}}; + + // trace1 is first: all its settings are used directly, + // and trace2 uses its size and shifts start to harmonize with it. + expect(calcPositions(trace1, [trace2])).toBeCloseToArray([ + 2.0, 2.6, 3.2 + ], 5); + expect(calcPositions(trace2, [trace1], true)).toBeCloseToArray([ + 5.0, 5.6, 6.2, 6.8 + ], 5); + + // switch the order: trace2 values win + expect(calcPositions(trace2, [trace1])).toBeCloseToArray([ + 4.9, 5.3, 5.7, 6.1, 6.5, 6.9 + ], 5); + expect(calcPositions(trace1, [trace2], true)).toBeCloseToArray([ + 2.1, 2.5, 2.9 + ], 5); + }); + + it('can take size and start from different traces in any order', function() { + var trace1 = {x: [1, 2, 3, 4], xbins: {size: 0.6}}; + var trace2 = {x: [5, 6, 7, 8], xbins: {start: 4.8}}; + + [true, false].forEach(function(prepend) { + expect(calcPositions(trace1, [trace2], prepend)).toBeCloseToArray([ + 0.9, 1.5, 2.1, 2.7, 3.3, 3.9 + ], 5); + + expect(calcPositions(trace2, [trace1], prepend)).toBeCloseToArray([ + 5.1, 5.7, 6.3, 6.9, 7.5, 8.1 + ], 5); + }); + }); + + it('works with only a size specified', function() { + // this used to not just lose the size, but actually errored out. + var trace1 = {x: [1, 2, 3, 4], xbins: {size: 0.8}}; + var trace2 = {x: [5, 6, 7, 8]}; + + [true, false].forEach(function(prepend) { + expect(calcPositions(trace1, [trace2], prepend)).toBeCloseToArray([ + 1, 1.8, 2.6, 3.4, 4.2 + ], 5); + + expect(calcPositions(trace2, [trace1], prepend)).toBeCloseToArray([ + 5, 5.8, 6.6, 7.4, 8.2 + ], 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(trace1, [trace2, trace3, trace4])).toEqual([1, 3, 5]); expect(calcPositions(trace3)).toBeCloseToArray([1.1, 1.3], 5); }); @@ -610,43 +659,59 @@ describe('Test histogram', function() { var data1 = [1.5, 2, 2, 3, 3, 3, 4, 4, 5]; Plotly.plot(gd, [{x: data1, type: 'histogram' }]); expect(gd._fullData[0].xbins).toEqual({start: 1, end: 6, size: 1}); - expect(gd._fullData[0].autobinx).toBe(true); + expect(gd._fullData[0].nbinsx).toBe(0); // same range but fewer samples changes autobin size var data2 = [1.5, 5]; Plotly.restyle(gd, 'x', [data2]); expect(gd._fullData[0].xbins).toEqual({start: -2.5, end: 7.5, size: 5}); - expect(gd._fullData[0].autobinx).toBe(true); + expect(gd._fullData[0].nbinsx).toBe(0); // different range var data3 = [10, 20.2, 20, 30, 30, 30, 40, 40, 50]; Plotly.restyle(gd, 'x', [data3]); expect(gd._fullData[0].xbins).toEqual({start: 5, end: 55, size: 10}); - expect(gd._fullData[0].autobinx).toBe(true); + expect(gd._fullData[0].nbinsx).toBe(0); - // explicit change to a bin attribute clears autobin + // explicit change to start does not update anything else Plotly.restyle(gd, 'xbins.start', 3); expect(gd._fullData[0].xbins).toEqual({start: 3, end: 55, size: 10}); - expect(gd._fullData[0].autobinx).toBe(false); + expect(gd._fullData[0].nbinsx).toBe(0); // restart autobin Plotly.restyle(gd, 'autobinx', true); expect(gd._fullData[0].xbins).toEqual({start: 5, end: 55, size: 10}); - expect(gd._fullData[0].autobinx).toBe(true); + expect(gd._fullData[0].nbinsx).toBe(0); + + // explicit end does not update anything else + Plotly.restyle(gd, 'xbins.end', 43); + expect(gd._fullData[0].xbins).toEqual({start: 5, end: 43, size: 10}); + expect(gd._fullData[0].nbinsx).toBe(0); + + // nbins would update all three, but explicit end is honored + Plotly.restyle(gd, 'nbinsx', 3); + expect(gd._fullData[0].xbins).toEqual({start: 0, end: 43, size: 20}); + expect(gd._fullData[0].nbinsx).toBe(3); + + // explicit size updates auto start *and* end, and moots nbins + Plotly.restyle(gd, {'xbins.end': null, 'xbins.size': 2}); + expect(gd._fullData[0].xbins).toEqual({start: 9, end: 51, size: 2}); + expect(gd._fullData[0].nbinsx).toBeUndefined(); }); it('respects explicit autobin: false as a one-time autobin', function() { var data1 = [1.5, 2, 2, 3, 3, 3, 4, 4, 5]; Plotly.plot(gd, [{x: data1, type: 'histogram', autobinx: false }]); // we have no bins, so even though autobin is false we have to autobin once + // but for backward compat. calc pushes these bins back into gd.data + // even though there's no `autobinx` attribute anymore. expect(gd._fullData[0].xbins).toEqual({start: 1, end: 6, size: 1}); - expect(gd._fullData[0].autobinx).toBe(false); + expect(gd.data[0].xbins).toEqual({start: 1, end: 6, size: 1}); // since autobin is false, this will not change the bins var data2 = [1.5, 5]; Plotly.restyle(gd, 'x', [data2]); expect(gd._fullData[0].xbins).toEqual({start: 1, end: 6, size: 1}); - expect(gd._fullData[0].autobinx).toBe(false); }); it('allows changing axis type with new x data', function() { @@ -742,6 +807,53 @@ describe('Test histogram', function() { .catch(failTest) .then(done); }); + + it('autobins all histograms (on the same subplot) together except `visible: false`', function(done) { + function _assertBinCenters(expectedCenters) { + var centers = gd.calcdata.map(function(cd) { + return cd.map(function(cdi) { return cdi.p; }); + }); + + expect(centers).toBeCloseTo2DArray(expectedCenters); + } + + var hidden = [undefined]; + + Plotly.newPlot(gd, [ + {type: 'histogram', x: [1]}, + {type: 'histogram', x: [10, 10.1, 10.2, 10.3]}, + {type: 'histogram', x: [20, 20, 20, 20, 20, 20, 20, 20, 20, 21]} + ]) + .then(function() { + _assertBinCenters([[0], [10], [20]]); + return Plotly.restyle(gd, 'visible', 'legendonly', [1, 2]); + }) + .then(function() { + _assertBinCenters([[0], hidden, hidden]); + return Plotly.restyle(gd, 'visible', false, [1, 2]); + }) + .then(function() { + _assertBinCenters([[1], hidden, hidden]); + return Plotly.restyle(gd, 'visible', [false, false, true]); + }) + .then(function() { + _assertBinCenters([hidden, hidden, [20, 21]]); + return Plotly.restyle(gd, 'visible', [false, true, false]); + }) + .then(function() { + _assertBinCenters([hidden, [10.1, 10.3], hidden]); + // only one trace is visible, despite traces being grouped + expect(gd._fullLayout.bargap).toBe(0); + return Plotly.restyle(gd, 'visible', ['legendonly', true, 'legendonly']); + }) + .then(function() { + _assertBinCenters([hidden, [10], hidden]); + // legendonly traces still flip us back to gapped + expect(gd._fullLayout.bargap).toBe(0.2); + }) + .catch(failTest) + .then(done); + }); }); }); diff --git a/test/jasmine/tests/lib_date_test.js b/test/jasmine/tests/lib_date_test.js index 2a0971afd7b..6bccb8a9865 100644 --- a/test/jasmine/tests/lib_date_test.js +++ b/test/jasmine/tests/lib_date_test.js @@ -391,20 +391,22 @@ describe('dates', function() { errors.push(msg); }); - [ + var cases = [ new Date(-20000, 0, 1), new Date(20000, 0, 1), new Date('fail'), undefined, null, NaN, [], {}, [0], {1: 2}, '', '2001-02-29' // not a leap year - ].forEach(function(v) { + ]; + cases.forEach(function(v) { expect(Lib.cleanDate(v)).toBeUndefined(); if(!isNumeric(+v)) expect(Lib.cleanDate(+v)).toBeUndefined(); expect(Lib.cleanDate(v, '2000-01-01')).toBe('2000-01-01'); }); - expect(errors.length).toBe(16); + // two errors for each case except `undefined` + expect(errors.length).toBe(2 * (cases.length - 1)); }); it('should not alter valid date strings, even to truncate them', function() { diff --git a/test/jasmine/tests/plot_api_test.js b/test/jasmine/tests/plot_api_test.js index 4e38fc1e982..42ad39c79f6 100644 --- a/test/jasmine/tests/plot_api_test.js +++ b/test/jasmine/tests/plot_api_test.js @@ -1119,17 +1119,30 @@ describe('Test plot api', function() { }); it('turns off autobin when you edit bin specs', function(done) { + // test retained (modified) for backward compat with new autobin logic var start0 = 0.2; var end1 = 6; var size1 = 0.5; function check(auto, msg) { - expect(gd.data[0].autobinx).toBe(auto, msg); - expect(gd.data[0].xbins.start).negateIf(auto).toBe(start0, msg); - expect(gd.data[1].autobinx).toBe(auto, msg); - expect(gd.data[1].autobiny).toBe(auto, msg); - expect(gd.data[1].xbins.end).negateIf(auto).toBe(end1, msg); - expect(gd.data[1].ybins.size).negateIf(auto).toBe(size1, msg); + expect(gd.data[0].autobinx).toBeUndefined(msg); + expect(gd.data[1].autobinx).toBeUndefined(msg); + expect(gd.data[1].autobiny).toBeUndefined(msg); + + if(auto) { + expect(gd.data[0].xbins).toBeUndefined(msg); + expect(gd.data[1].xbins).toBeUndefined(msg); + expect(gd.data[1].ybins).toBeUndefined(msg); + } + else { + // we can have - and use - partial autobin now + expect(gd.data[0].xbins).toEqual({start: start0}); + expect(gd.data[1].xbins).toEqual({end: end1}); + expect(gd.data[1].ybins).toEqual({size: size1}); + expect(gd._fullData[0].xbins.start).toBe(start0, msg); + expect(gd._fullData[1].xbins.end).toBe(end1, msg); + expect(gd._fullData[1].ybins.size).toBe(size1, msg); + } } Plotly.plot(gd, [ diff --git a/test/jasmine/tests/validate_test.js b/test/jasmine/tests/validate_test.js index d3123bd3a1a..0a455f2b4e5 100644 --- a/test/jasmine/tests/validate_test.js +++ b/test/jasmine/tests/validate_test.js @@ -568,4 +568,73 @@ describe('Plotly.validate', function() { 'In layout, container polar3 did not get coerced' ); }); + + it('understands histogram bin and autobin attributes', function() { + var out = Plotly.validate([{ + type: 'histogram', + x: [1, 2, 3], + // allowed by Plotly.validate, even though we get rid of it + // in a real plot call + autobinx: true, + // valid attribute, but not coerced + autobiny: false + }]); + expect(out.length).toBe(1); + assertErrorContent( + out[0], 'unused', 'data', 0, ['autobiny'], 'autobiny', + 'In data trace 0, key autobiny did not get coerced' + ); + + out = Plotly.validate([{ + type: 'histogram', + x: [1, 2, 3], + xbins: {start: 1, end: 4, size: 0.5} + }]); + expect(out).toBeUndefined(); + + out = Plotly.validate([{ + type: 'histogram', + x: [1, 2, 3], + xbins: {start: 0.8, end: 4, size: 0.5} + }, { + type: 'histogram', + x: [1, 2, 3], + // start and end still get coerced, even though start will get modified + // during calc. size will not be coerced because trace 0 already has it. + xbins: {start: 2, end: 3, size: 1} + }]); + + expect(out.length).toBe(1); + assertErrorContent( + out[0], 'unused', 'data', 1, ['xbins', 'size'], 'xbins.size', + 'In data trace 1, key xbins.size did not get coerced' + ); + }); + + it('understands histogram2d(contour) bin and autobin attributes', function() { + var out = Plotly.validate([{ + type: 'histogram2d', + x: [1, 2, 3], + y: [1, 2, 3], + autobinx: true, + autobiny: false, + xbins: {start: 5, end: 10}, + ybins: {size: 2} + }, { + type: 'histogram2d', + x: [1, 2, 3], + y: [1, 2, 3], + xbins: {start: 0, end: 7, size: 1}, + ybins: {size: 3} + }, { + type: 'histogram2dcontour', + x: [1, 2, 3], + y: [1, 2, 3], + autobinx: false, + autobiny: false, + xbins: {start: 1, end: 5, size: 2}, + ybins: {size: 4} + }]); + expect(out).toBeUndefined(); + }); });