Skip to content

Commit

Permalink
Merge pull request #3044 from plotly/histogram-autobin
Browse files Browse the repository at this point in the history
Histogram autobin
  • Loading branch information
alexcjohnson authored Oct 4, 2018
2 parents 48209a0 + 7909f53 commit 30ed4a4
Show file tree
Hide file tree
Showing 35 changed files with 1,058 additions and 527 deletions.
4 changes: 3 additions & 1 deletion src/lib/dates.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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);
}
}
}

Expand Down
82 changes: 47 additions & 35 deletions src/plots/cartesian/axes.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Loading

0 comments on commit 30ed4a4

Please sign in to comment.