Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Histogram autobin #3044

Merged
merged 10 commits into from Oct 4, 2018
4 changes: 3 additions & 1 deletion src/lib/dates.js
Expand Up @@ -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)) {
Expand Down
13 changes: 13 additions & 0 deletions src/plot_api/helpers.js
Expand Up @@ -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;
}
}
};

Expand Down
33 changes: 28 additions & 5 deletions src/plot_api/plot_api.js
Expand Up @@ -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) {
Expand All @@ -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') {
Expand Down Expand Up @@ -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++) {
Expand All @@ -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);
etpinard marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

Expand Down
82 changes: 47 additions & 35 deletions src/plots/cartesian/axes.js
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = {
Expand All @@ -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.
Expand All @@ -354,23 +366,23 @@ 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
// just increment until you're done
binEnd = binStart;
bincount = 0;
while(binEnd <= dataMax) {
binEnd = axes.tickIncrement(binEnd, dummyAx.dtick, false, calendar);
binEnd = axes.tickIncrement(binEnd, finalSize, false, calendar);
bincount++;
}
}

return {
start: ax.c2r(binStart, 0, calendar),
end: ax.c2r(binEnd, 0, calendar),
size: dummyAx.dtick,
size: finalSize,
_dataSpan: dataMax - dataMin
};
};
Expand Down
87 changes: 87 additions & 0 deletions 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<n>" gives ticks every (integer) n months
(isDate && prefix === 'M' && dtickNum === Math.round(dtickNum)) ||
// "L<f>" 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;
};
50 changes: 6 additions & 44 deletions src/plots/cartesian/tick_value_defaults.js
Expand Up @@ -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) {
Expand All @@ -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<n>" gives ticks every (integer) n months
(axType === 'date' && prefix === 'M' && dtickNum === Math.round(dtickNum)) ||
// "L<f>" 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');
Expand Down