diff --git a/devtools/test_dashboard/perf.js b/devtools/test_dashboard/perf.js index 99d661046c7..e338e14f77b 100644 --- a/devtools/test_dashboard/perf.js +++ b/devtools/test_dashboard/perf.js @@ -42,7 +42,7 @@ window.timeit = function(f, n, nchunk, arg) { times.sort(); var min = (times[0]).toFixed(4); var max = (times[n - 1]).toFixed(4); - var median = (times[Math.ceil(n / 2)]).toFixed(4); + var median = (times[Math.min(Math.ceil(n / 2), n - 1)]).toFixed(4); var mean = (totalTime / n).toFixed(4); console.log((f.name || 'function') + ' timing (ms) - min: ' + min + ' max: ' + max + diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index cc366193eb3..6f32c7b3f78 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -1063,8 +1063,7 @@ function cleanPoint(d, hovermode) { d.y0 = Lib.constrain(d.y0, 0, d.ya._length); d.y1 = Lib.constrain(d.y1, 0, d.ya._length); - // and convert the x and y label values into objects - // formatted as text, with font info + // and convert the x and y label values into formatted text if(d.xLabelVal !== undefined) { d.xLabel = ('xLabel' in d) ? d.xLabel : Axes.hoverLabelText(d.xa, d.xLabelVal); d.xVal = d.xa.c2d(d.xLabelVal); diff --git a/src/lib/search.js b/src/lib/search.js index 8cb4275f1f3..f622d24b2cc 100644 --- a/src/lib/search.js +++ b/src/lib/search.js @@ -12,6 +12,11 @@ var isNumeric = require('fast-isnumeric'); var loggers = require('./loggers'); +// don't trust floating point equality - fraction of bin size to call +// "on the line" and ensure that they go the right way specified by +// linelow +var roundingError = 1e-9; + /** * findBin - find the bin for val - note that it can return outside the @@ -26,20 +31,21 @@ var loggers = require('./loggers'); exports.findBin = function(val, bins, linelow) { if(isNumeric(bins.start)) { return linelow ? - Math.ceil((val - bins.start) / bins.size) - 1 : - Math.floor((val - bins.start) / bins.size); + Math.ceil((val - bins.start) / bins.size - roundingError) - 1 : + Math.floor((val - bins.start) / bins.size + roundingError); } else { - var n1 = 0, - n2 = bins.length, - c = 0, - n, - test; - if(bins[bins.length - 1] >= bins[0]) { + var n1 = 0; + var n2 = bins.length; + var c = 0; + var binSize = (n2 > 1) ? (bins[n2 - 1] - bins[0]) / (n2 - 1) : 1; + var n, test; + if(binSize >= 0) { test = linelow ? lessThan : lessOrEqual; } else { test = linelow ? greaterOrEqual : greaterThan; } + val += binSize * roundingError * (linelow ? -1 : 1) * (binSize >= 0 ? 1 : -1); // c is just to avoid infinite loops if there's an error while(n1 < n2 && c++ < 100) { n = Math.floor((n1 + n2) / 2); diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index e2e7a01e1b7..3b24d00cc41 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -28,6 +28,7 @@ var ONEHOUR = constants.ONEHOUR; var ONEMIN = constants.ONEMIN; var ONESEC = constants.ONESEC; var MINUS_SIGN = constants.MINUS_SIGN; +var BADNUM = constants.BADNUM; var MID_SHIFT = require('../../constants/alignment').MID_SHIFT; @@ -1216,12 +1217,28 @@ axes.tickText = function(ax, x, hover) { return out; }; -axes.hoverLabelText = function(ax, val) { +/** + * create text for a hover label on this axis, with special handling of + * log axes (where negative values can't be displayed but can appear in hover text) + * + * @param {object} ax: the axis to format text for + * @param {number} val: calcdata value to format + * @param {Optional(number)} val2: a second value to display + * + * @returns {string} `val` formatted as a string appropriate to this axis, or + * `val` and `val2` as a range (ie ' - ') if `val2` is provided and + * it's different from `val`. + */ +axes.hoverLabelText = function(ax, val, val2) { + if(val2 !== BADNUM && val2 !== val) { + return axes.hoverLabelText(ax, val) + ' - ' + axes.hoverLabelText(ax, val2); + } + var logOffScale = (ax.type === 'log' && val <= 0); var tx = axes.tickText(ax, ax.c2l(logOffScale ? -val : val), 'hover').text; if(logOffScale) { - return val === 0 ? '0' : '-' + tx; + return val === 0 ? '0' : MINUS_SIGN + tx; } // TODO: should we do something special if the axis calendar and diff --git a/src/traces/bar/hover.js b/src/traces/bar/hover.js index 971798d6334..bea195126a9 100644 --- a/src/traces/bar/hover.js +++ b/src/traces/bar/hover.js @@ -18,37 +18,13 @@ module.exports = function hoverPoints(pointData, xval, yval, hovermode) { var cd = pointData.cd; var trace = cd[0].trace; var t = cd[0].t; - var xa = pointData.xa; - var ya = pointData.ya; - var posVal, thisBarMinPos, thisBarMaxPos, minPos, maxPos, dx, dy; + var posVal, sizeVal, posLetter, sizeLetter, dx, dy; - var positionFn = function(di) { - return Fx.inbox(minPos(di) - posVal, maxPos(di) - posVal); - }; + function thisBarMinPos(di) { return di[posLetter] - di.w / 2; } + function thisBarMaxPos(di) { return di[posLetter] + di.w / 2; } - if(trace.orientation === 'h') { - posVal = yval; - thisBarMinPos = function(di) { return di.y - di.w / 2; }; - thisBarMaxPos = function(di) { return di.y + di.w / 2; }; - dx = function(di) { - // add a gradient so hovering near the end of a - // bar makes it a little closer match - return Fx.inbox(di.b - xval, di.x - xval) + (di.x - xval) / (di.x - di.b); - }; - dy = positionFn; - } - else { - posVal = xval; - thisBarMinPos = function(di) { return di.x - di.w / 2; }; - thisBarMaxPos = function(di) { return di.x + di.w / 2; }; - dy = function(di) { - return Fx.inbox(di.b - yval, di.y - yval) + (di.y - yval) / (di.y - di.b); - }; - dx = positionFn; - } - - minPos = (hovermode === 'closest') ? + var minPos = (hovermode === 'closest') ? thisBarMinPos : function(di) { /* @@ -60,12 +36,43 @@ module.exports = function hoverPoints(pointData, xval, yval, hovermode) { return Math.min(thisBarMinPos(di), di.p - t.bargroupwidth / 2); }; - maxPos = (hovermode === 'closest') ? + var maxPos = (hovermode === 'closest') ? thisBarMaxPos : function(di) { return Math.max(thisBarMaxPos(di), di.p + t.bargroupwidth / 2); }; + function positionFn(di) { + return Fx.inbox(minPos(di) - posVal, maxPos(di) - posVal); + } + + function sizeFn(di) { + // add a gradient so hovering near the end of a + // bar makes it a little closer match + return Fx.inbox(di.b - sizeVal, di[sizeLetter] - sizeVal) + + (di[sizeLetter] - sizeVal) / (di[sizeLetter] - di.b); + } + + if(trace.orientation === 'h') { + posVal = yval; + sizeVal = xval; + posLetter = 'y'; + sizeLetter = 'x'; + dx = sizeFn; + dy = positionFn; + } + else { + posVal = xval; + sizeVal = yval; + posLetter = 'x'; + sizeLetter = 'y'; + dy = sizeFn; + dx = positionFn; + } + + var pa = pointData[posLetter + 'a']; + var sa = pointData[sizeLetter + 'a']; + var distfn = Fx.getDistanceFunction(hovermode, dx, dy); Fx.getClosest(cd, distfn, pointData); @@ -73,31 +80,22 @@ module.exports = function hoverPoints(pointData, xval, yval, hovermode) { if(pointData.index === false) return; // the closest data point - var index = pointData.index, - di = cd[index], - mc = di.mcc || trace.marker.color, - mlc = di.mlcc || trace.marker.line.color, - mlw = di.mlw || trace.marker.line.width; + var index = pointData.index; + var di = cd[index]; + var mc = di.mcc || trace.marker.color; + var mlc = di.mlcc || trace.marker.line.color; + var mlw = di.mlw || trace.marker.line.width; + if(Color.opacity(mc)) pointData.color = mc; else if(Color.opacity(mlc) && mlw) pointData.color = mlc; var size = (trace.base) ? di.b + di.s : di.s; - if(trace.orientation === 'h') { - pointData.x0 = pointData.x1 = xa.c2p(di.x, true); - pointData.xLabelVal = size; + pointData[sizeLetter + '0'] = pointData[sizeLetter + '1'] = sa.c2p(di[sizeLetter], true); + pointData[sizeLetter + 'LabelVal'] = size; - pointData.y0 = ya.c2p(minPos(di), true); - pointData.y1 = ya.c2p(maxPos(di), true); - pointData.yLabelVal = di.p; - } - else { - pointData.y0 = pointData.y1 = ya.c2p(di.y, true); - pointData.yLabelVal = size; - - pointData.x0 = xa.c2p(minPos(di), true); - pointData.x1 = xa.c2p(maxPos(di), true); - pointData.xLabelVal = di.p; - } + pointData[posLetter + '0'] = pa.c2p(minPos(di), true); + pointData[posLetter + '1'] = pa.c2p(maxPos(di), true); + pointData[posLetter + 'LabelVal'] = di.p; fillHoverText(di, trace, pointData); ErrorBars.hoverInfo(di, trace, pointData); diff --git a/src/traces/heatmap/calc.js b/src/traces/heatmap/calc.js index cad3f3cf478..b8ce599d5ed 100644 --- a/src/traces/heatmap/calc.js +++ b/src/traces/heatmap/calc.js @@ -40,14 +40,15 @@ module.exports = function calc(gd, trace) { y0, dy, z, - i; + i, + binned; // cancel minimum tick spacings (only applies to bars and boxes) xa._minDtick = 0; ya._minDtick = 0; if(isHist) { - var binned = histogram2dCalc(gd, trace); + binned = histogram2dCalc(gd, trace); x = binned.x; x0 = binned.x0; dx = binned.dx; @@ -128,6 +129,12 @@ module.exports = function calc(gd, trace) { var cd0 = {x: xArray, y: yArray, z: z, text: trace.text}; + if(isHist) { + cd0.xRanges = binned.xRanges; + cd0.yRanges = binned.yRanges; + cd0.pts = binned.pts; + } + // auto-z and autocolorscale if applicable colorscaleCalc(trace, z, '', 'z'); diff --git a/src/traces/histogram/bin_label_vals.js b/src/traces/histogram/bin_label_vals.js new file mode 100644 index 00000000000..8c46584fe35 --- /dev/null +++ b/src/traces/histogram/bin_label_vals.js @@ -0,0 +1,176 @@ +/** +* Copyright 2012-2017, 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 numConstants = require('../../constants/numerical'); +var oneYear = numConstants.ONEAVGYEAR; +var oneMonth = numConstants.ONEAVGMONTH; +var oneDay = numConstants.ONEDAY; +var oneHour = numConstants.ONEHOUR; +var oneMin = numConstants.ONEMIN; +var oneSec = numConstants.ONESEC; +var tickIncrement = require('../../plots/cartesian/axes').tickIncrement; + + +/* + * make a function that will find rounded bin edges + * @param {number} leftGap: how far from the left edge of any bin is the closest data value? + * @param {number} rightGap: how far from the right edge of any bin is the closest data value? + * @param {Array[number]} binEdges: the actual edge values used in binning + * @param {object} pa: the position axis + * @param {string} calendar: the data calendar + * + * @return {function(v, isRightEdge)}: + * find the start (isRightEdge is falsy) or end (truthy) label value for a bin edge `v` + */ +module.exports = function getBinSpanLabelRound(leftGap, rightGap, binEdges, pa, calendar) { + // the rounding digit is the largest digit that changes in *all* of 4 regions: + // - inside the rightGap before binEdges[0] (shifted 10% to the left) + // - inside the leftGap after binEdges[0] (expanded by 10% of rightGap on each end) + // - same for binEdges[1] + var dv0 = -1.1 * rightGap; + var dv1 = -0.1 * rightGap; + var dv2 = leftGap - dv1; + var edge0 = binEdges[0]; + var edge1 = binEdges[1]; + var leftDigit = Math.min( + biggestDigitChanged(edge0 + dv1, edge0 + dv2, pa, calendar), + biggestDigitChanged(edge1 + dv1, edge1 + dv2, pa, calendar) + ); + var rightDigit = Math.min( + biggestDigitChanged(edge0 + dv0, edge0 + dv1, pa, calendar), + biggestDigitChanged(edge1 + dv0, edge1 + dv1, pa, calendar) + ); + + // normally we try to make the label for the right edge different from + // the left edge label, so it's unambiguous which bin gets data on the edge. + // but if this results in more than 3 extra digits (or for dates, more than + // 2 fields ie hr&min or min&sec, which is 3600x), it'll be more clutter than + // useful so keep the label cleaner instead + var digit, disambiguateEdges; + if(leftDigit > rightDigit && rightDigit < Math.abs(edge1 - edge0) / 4000) { + digit = leftDigit; + disambiguateEdges = false; + } + else { + digit = Math.min(leftDigit, rightDigit); + disambiguateEdges = true; + } + + if(pa.type === 'date' && digit > oneDay) { + var dashExclude = (digit === oneYear) ? 1 : 6; + var increment = (digit === oneYear) ? 'M12' : 'M1'; + + return function(v, isRightEdge) { + var dateStr = pa.c2d(v, oneYear, calendar); + var dashPos = dateStr.indexOf('-', dashExclude); + if(dashPos > 0) dateStr = dateStr.substr(0, dashPos); + var roundedV = pa.d2c(dateStr, 0, calendar); + + if(roundedV < v) { + var nextV = tickIncrement(roundedV, increment, false, calendar); + if((roundedV + nextV) / 2 < v + leftGap) roundedV = nextV; + } + + if(isRightEdge && disambiguateEdges) { + return tickIncrement(roundedV, increment, true, calendar); + } + + return roundedV; + }; + } + + return function(v, isRightEdge) { + var roundedV = digit * Math.round(v / digit); + // if we rounded down and we could round up and still be < leftGap + // (or what leftGap values round to), do that + if(roundedV + (digit / 10) < v && roundedV + (digit * 0.9) < v + leftGap) { + roundedV += digit; + } + // finally for the right edge back off one digit - but only if we can do that + // and not clip off any data that's potentially in the bin + if(isRightEdge && disambiguateEdges) { + roundedV -= digit; + } + return roundedV; + }; +}; + +/* + * Find the largest digit that changes within a (calcdata) region [v1, v2] + * if dates, "digit" means date/time part when it's bigger than a second + * returns the unit value to round to this digit, eg 0.01 to round to hundredths, or + * 100 to round to hundreds. returns oneMonth or oneYear for month or year rounding, + * so that Math.min will work, rather than 'M1' and 'M12' + */ +function biggestDigitChanged(v1, v2, pa, calendar) { + // are we crossing zero? can't say anything. + // in principle this doesn't apply to dates but turns out this doesn't matter. + if(v1 * v2 <= 0) return Infinity; + + var dv = Math.abs(v2 - v1); + var isDate = pa.type === 'date'; + var digit = biggestGuaranteedDigitChanged(dv, isDate); + // see if a larger digit also changed + for(var i = 0; i < 10; i++) { + // numbers: next digit needs to be >10x but <100x then gets rounded down. + // dates: next digit can be as much as 60x (then rounded down) + var nextDigit = biggestGuaranteedDigitChanged(digit * 80, isDate); + // if we get to years, the chain stops + if(digit === nextDigit) break; + if(didDigitChange(nextDigit, v1, v2, isDate, pa, calendar)) digit = nextDigit; + else break; + } + return digit; +} + +/* + * Find the largest digit that *definitely* changes in a region [v, v + dv] for any v + * for nonuniform date regions (months/years) pick the largest + */ +function biggestGuaranteedDigitChanged(dv, isDate) { + if(isDate && dv > oneSec) { + // this is supposed to be the biggest *guaranteed* change + // so compare to the longest month and year across any calendar, + // and we'll iterate back up later + // note: does not support rounding larger than one year. We could add + // that if anyone wants it, but seems unusual and not strictly necessary. + if(dv > oneDay) { + if(dv > oneYear * 1.1) return oneYear; + if(dv > oneMonth * 1.1) return oneMonth; + return oneDay; + } + + if(dv > oneHour) return oneHour; + if(dv > oneMin) return oneMin; + return oneSec; + } + return Math.pow(10, Math.floor(Math.log(dv) / Math.LN10)); +} + +function didDigitChange(digit, v1, v2, isDate, pa, calendar) { + if(isDate && digit > oneDay) { + var dateParts1 = dateParts(v1, pa, calendar); + var dateParts2 = dateParts(v2, pa, calendar); + var parti = (digit === oneYear) ? 0 : 1; + return dateParts1[parti] !== dateParts2[parti]; + + } + return Math.floor(v2 / digit) - Math.floor(v1 / digit) > 0.1; +} + +function dateParts(v, pa, calendar) { + var parts = pa.c2d(v, oneYear, calendar).split('-'); + if(parts[0] === '') { + parts.unshift(); + parts[0] = '-' + parts[0]; + } + return parts; +} diff --git a/src/traces/histogram/calc.js b/src/traces/histogram/calc.js index b80c8d4f88b..ebf22aaf7f2 100644 --- a/src/traces/histogram/calc.js +++ b/src/traces/histogram/calc.js @@ -20,6 +20,7 @@ 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) { @@ -45,10 +46,12 @@ module.exports = function calc(gd, trace) { var pos0 = binsAndPos[1]; var nonuniformBins = typeof binSpec.size === 'string'; - var bins = nonuniformBins ? [] : binSpec; + var binEdges = []; + var bins = nonuniformBins ? binEdges : binSpec; // make the empty bin array var inc = []; var counts = []; + var inputPoints = []; var total = 0; var norm = trace.histnorm; var func = trace.histfunc; @@ -87,9 +90,10 @@ module.exports = function calc(gd, trace) { i2 = Axes.tickIncrement(i, binSpec.size, false, calendar); pos.push((i + i2) / 2); size.push(sizeInit); + inputPoints.push([]); // nonuniform bins (like months) we need to search, // rather than straight calculate the bin we're in - if(nonuniformBins) bins.push(i); + binEdges.push(i); // nonuniform bins also need nonuniform normalization factors if(densityNorm) inc.push(1 / (i2 - i)); if(isAvg) counts.push(0); @@ -97,6 +101,7 @@ module.exports = function calc(gd, trace) { if(i2 <= i) break; i = i2; } + binEdges.push(i); // for date axes we need bin bounds to be calcdata. For nonuniform bins // we already have this, but uniform with start/end/size they're still strings. @@ -109,10 +114,28 @@ module.exports = function calc(gd, trace) { } var nMax = size.length; + var uniqueValsPerBin = true; + var leftGap = Infinity; + var rightGap = Infinity; // bin the data for(i = 0; i < pos0.length; i++) { - n = Lib.findBin(pos0[i], bins); - if(n >= 0 && n < nMax) total += binFunc(n, i, size, rawCounterData, counts); + var posi = pos0[i]; + n = Lib.findBin(posi, bins); + if(n >= 0 && n < nMax) { + total += binFunc(n, i, size, rawCounterData, counts); + if(uniqueValsPerBin && inputPoints[n].length && posi !== pos0[inputPoints[n][0]]) { + uniqueValsPerBin = false; + } + inputPoints[n].push(i); + + leftGap = Math.min(leftGap, posi - binEdges[n]); + rightGap = Math.min(rightGap, binEdges[n + 1] - posi); + } + } + + var roundFn; + if(!uniqueValsPerBin) { + roundFn = getBinSpanLabelRound(leftGap, rightGap, binEdges, pa, calendar); } // average and/or normalize the data, if needed @@ -145,7 +168,24 @@ module.exports = function calc(gd, trace) { // create the "calculated data" to plot for(i = firstNonzero; i <= lastNonzero; i++) { if((isNumeric(pos[i]) && isNumeric(size[i]))) { - cd.push({p: pos[i], s: size[i], b: 0}); + var cdi = { + p: pos[i], + s: size[i], + b: 0 + }; + + // pts and p0/p1 don't seem to make much sense for cumulative distributions + if(!cumulativeSpec.enabled) { + cdi.pts = inputPoints[i]; + if(uniqueValsPerBin) { + cdi.p0 = cdi.p1 = (inputPoints[i].length) ? pos0[inputPoints[i][0]] : pos[i]; + } + else { + cdi.p0 = roundFn(binEdges[i]); + cdi.p1 = roundFn(binEdges[i + 1], true); + } + } + cd.push(cdi); } } diff --git a/src/traces/histogram/event_data.js b/src/traces/histogram/event_data.js new file mode 100644 index 00000000000..7c39678087d --- /dev/null +++ b/src/traces/histogram/event_data.js @@ -0,0 +1,29 @@ +/** +* Copyright 2012-2017, 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 eventData(out, pt) { + // standard cartesian event data + out.x = pt.xVal; + out.y = pt.yVal; + out.xaxis = pt.xa; + out.yaxis = pt.ya; + + // specific to histogram + // CDFs do not have pts (yet?) + if(pt.pts) { + out.pointNumbers = pt.pts; + out.binNumber = out.pointNumber; + delete out.pointNumber; + } + + return out; +}; diff --git a/src/traces/histogram/hover.js b/src/traces/histogram/hover.js new file mode 100644 index 00000000000..5bfe0317840 --- /dev/null +++ b/src/traces/histogram/hover.js @@ -0,0 +1,32 @@ +/** +* Copyright 2012-2017, 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 barHover = require('../bar/hover'); +var hoverLabelText = require('../../plots/cartesian/axes').hoverLabelText; + +module.exports = function hoverPoints(pointData, xval, yval, hovermode) { + var pts = barHover(pointData, xval, yval, hovermode); + + if(!pts) return; + + pointData = pts[0]; + var di = pointData.cd[pointData.index]; + var trace = pointData.cd[0].trace; + + if(!trace.cumulative.enabled) { + var posLetter = trace.orientation === 'h' ? 'y' : 'x'; + + pointData[posLetter + 'Label'] = hoverLabelText(pointData[posLetter + 'a'], di.p0, di.p1); + pointData.pts = di.pts; + } + + return pts; +}; diff --git a/src/traces/histogram/index.js b/src/traces/histogram/index.js index d0cfeae5d63..89b3cceccf0 100644 --- a/src/traces/histogram/index.js +++ b/src/traces/histogram/index.js @@ -34,8 +34,9 @@ Histogram.setPositions = require('../bar/set_positions'); Histogram.plot = require('../bar/plot'); Histogram.style = require('../bar/style'); Histogram.colorbar = require('../scatter/colorbar'); -Histogram.hoverPoints = require('../bar/hover'); +Histogram.hoverPoints = require('./hover'); Histogram.selectPoints = require('../bar/select'); +Histogram.eventData = require('./event_data'); Histogram.moduleType = 'trace'; Histogram.name = 'histogram'; diff --git a/src/traces/histogram2d/calc.js b/src/traces/histogram2d/calc.js index 4483b938fad..220fad85516 100644 --- a/src/traces/histogram2d/calc.js +++ b/src/traces/histogram2d/calc.js @@ -16,96 +16,60 @@ 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'); module.exports = function calc(gd, trace) { - var xa = Axes.getFromId(gd, trace.xaxis || 'x'), - x = trace.x ? xa.makeCalcdata(trace, 'x') : [], - ya = Axes.getFromId(gd, trace.yaxis || 'y'), - y = trace.y ? ya.makeCalcdata(trace, 'y') : [], - xcalendar = trace.xcalendar, - ycalendar = trace.ycalendar, - xr2c = function(v) { return xa.r2c(v, 0, xcalendar); }, - yr2c = function(v) { return ya.r2c(v, 0, ycalendar); }, - xc2r = function(v) { return xa.c2r(v, 0, xcalendar); }, - yc2r = function(v) { return ya.c2r(v, 0, ycalendar); }, - x0, - dx, - y0, - dy, - z, - i; - - cleanBins(trace, xa, 'x'); - cleanBins(trace, ya, 'y'); + var xa = Axes.getFromId(gd, trace.xaxis || 'x'); + var x = trace.x ? xa.makeCalcdata(trace, 'x') : []; + var ya = Axes.getFromId(gd, trace.yaxis || 'y'); + var y = trace.y ? ya.makeCalcdata(trace, 'y') : []; + var xcalendar = trace.xcalendar; + var ycalendar = trace.ycalendar; + var xr2c = function(v) { return xa.r2c(v, 0, xcalendar); }; + var yr2c = function(v) { return ya.r2c(v, 0, ycalendar); }; + var xc2r = function(v) { return xa.c2r(v, 0, xcalendar); }; + var yc2r = function(v) { return ya.c2r(v, 0, ycalendar); }; + + var i, j, n, m; var serieslen = Math.min(x.length, y.length); if(x.length > serieslen) x.splice(serieslen, x.length - serieslen); if(y.length > serieslen) y.splice(serieslen, y.length - serieslen); - // calculate the bins - if(trace.autobinx || !trace.xbins || - trace.xbins.start === null || trace.xbins.end === null) { - trace.xbins = Axes.autoBin(x, xa, trace.nbinsx, '2d', xcalendar); - if(trace.type === 'histogram2dcontour') { - // the "true" last argument reverses the tick direction (which we can't - // just do with a minus sign because of month bins) - trace.xbins.start = xc2r(Axes.tickIncrement( - xr2c(trace.xbins.start), trace.xbins.size, true, xcalendar)); - trace.xbins.end = xc2r(Axes.tickIncrement( - xr2c(trace.xbins.end), trace.xbins.size, false, xcalendar)); - } - - // copy bin info back to the source data. - trace._input.xbins = trace.xbins; - // 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.autobinx = trace.autobinx; - } - if(trace.autobiny || !trace.ybins || - trace.ybins.start === null || trace.ybins.end === null) { - trace.ybins = Axes.autoBin(y, ya, trace.nbinsy, '2d', ycalendar); - if(trace.type === 'histogram2dcontour') { - trace.ybins.start = yc2r(Axes.tickIncrement( - yr2c(trace.ybins.start), trace.ybins.size, true, ycalendar)); - trace.ybins.end = yc2r(Axes.tickIncrement( - yr2c(trace.ybins.end), trace.ybins.size, false, ycalendar)); - } - trace._input.ybins = trace.ybins; - trace._input.autobiny = trace.autobiny; - } + cleanAndAutobin(trace, 'x', x, xa, xr2c, xc2r, xcalendar); + cleanAndAutobin(trace, 'y', y, ya, yr2c, yc2r, ycalendar); // make the empty bin array & scale the map - z = []; - var onecol = [], - zerocol = [], - nonuniformBinsX = (typeof(trace.xbins.size) === 'string'), - nonuniformBinsY = (typeof(trace.ybins.size) === 'string'), - xbins = nonuniformBinsX ? [] : trace.xbins, - ybins = nonuniformBinsY ? [] : trace.ybins, - total = 0, - n, - m, - counts = [], - norm = trace.histnorm, - func = trace.histfunc, - densitynorm = (norm.indexOf('density') !== -1), - extremefunc = (func === 'max' || func === 'min'), - sizeinit = (extremefunc ? null : 0), - binfunc = binFunctions.count, - normfunc = normFunctions[norm], - doavg = false, - xinc = [], - yinc = []; + var z = []; + var onecol = []; + var zerocol = []; + var nonuniformBinsX = (typeof(trace.xbins.size) === 'string'); + var nonuniformBinsY = (typeof(trace.ybins.size) === 'string'); + var xEdges = []; + var yEdges = []; + var xbins = nonuniformBinsX ? xEdges : trace.xbins; + var ybins = nonuniformBinsY ? yEdges : trace.ybins; + var total = 0; + var counts = []; + var inputPoints = []; + var norm = trace.histnorm; + var func = trace.histfunc; + var densitynorm = (norm.indexOf('density') !== -1); + var extremefunc = (func === 'max' || func === 'min'); + var sizeinit = (extremefunc ? null : 0); + var binfunc = binFunctions.count; + var normfunc = normFunctions[norm]; + var doavg = false; + var xinc = []; + var yinc = []; // set a binning function other than count? // for binning functions: check first for 'z', // then 'mc' in case we had a colored scatter plot // and want to transfer these colors to the 2D histo - // TODO: this is why we need a data picker in the popover... + // TODO: axe this, make it the responsibility of the app changing type? or an impliedEdit? var rawCounterData = ('z' in trace) ? trace.z : (('marker' in trace && Array.isArray(trace.marker.color)) ? @@ -116,77 +80,84 @@ module.exports = function calc(gd, trace) { } // decrease end a little in case of rounding errors - var binspec = trace.xbins, - binStart = xr2c(binspec.start), - binEnd = xr2c(binspec.end) + - (binStart - Axes.tickIncrement(binStart, binspec.size, false, xcalendar)) / 1e6; + var binSpec = trace.xbins, + binStart = xr2c(binSpec.start), + binEnd = xr2c(binSpec.end) + + (binStart - Axes.tickIncrement(binStart, binSpec.size, false, xcalendar)) / 1e6; - for(i = binStart; i < binEnd; i = Axes.tickIncrement(i, binspec.size, false, xcalendar)) { + for(i = binStart; i < binEnd; i = Axes.tickIncrement(i, binSpec.size, false, xcalendar)) { onecol.push(sizeinit); - if(nonuniformBinsX) xbins.push(i); + xEdges.push(i); if(doavg) zerocol.push(0); } - if(nonuniformBinsX) xbins.push(i); + xEdges.push(i); var nx = onecol.length; - x0 = trace.xbins.start; - var x0c = xr2c(x0); - dx = (i - x0c) / nx; - x0 = xc2r(x0c + dx / 2); - - binspec = trace.ybins; - binStart = yr2c(binspec.start); - binEnd = yr2c(binspec.end) + - (binStart - Axes.tickIncrement(binStart, binspec.size, false, ycalendar)) / 1e6; - - for(i = binStart; i < binEnd; i = Axes.tickIncrement(i, binspec.size, false, ycalendar)) { - z.push(onecol.concat()); - if(nonuniformBinsY) ybins.push(i); - if(doavg) counts.push(zerocol.concat()); + var x0c = xr2c(trace.xbins.start); + var dx = (i - x0c) / nx; + var x0 = xc2r(x0c + dx / 2); + + binSpec = trace.ybins; + binStart = yr2c(binSpec.start); + binEnd = yr2c(binSpec.end) + + (binStart - Axes.tickIncrement(binStart, binSpec.size, false, ycalendar)) / 1e6; + + for(i = binStart; i < binEnd; i = Axes.tickIncrement(i, binSpec.size, false, ycalendar)) { + z.push(onecol.slice()); + yEdges.push(i); + var ipCol = new Array(nx); + for(j = 0; j < nx; j++) ipCol[j] = []; + inputPoints.push(ipCol); + if(doavg) counts.push(zerocol.slice()); } - if(nonuniformBinsY) ybins.push(i); + yEdges.push(i); var ny = z.length; - y0 = trace.ybins.start; - var y0c = yr2c(y0); - dy = (i - y0c) / ny; - y0 = yc2r(y0c + dy / 2); + var y0c = yr2c(trace.ybins.start); + var dy = (i - y0c) / ny; + var y0 = yc2r(y0c + dy / 2); if(densitynorm) { - xinc = onecol.map(function(v, i) { - if(nonuniformBinsX) return 1 / (xbins[i + 1] - xbins[i]); - return 1 / dx; - }); - yinc = z.map(function(v, i) { - if(nonuniformBinsY) return 1 / (ybins[i + 1] - ybins[i]); - return 1 / dy; - }); + xinc = makeIncrements(onecol.length, xbins, dx, nonuniformBinsX); + yinc = makeIncrements(z.length, ybins, dy, nonuniformBinsY); } // for date axes we need bin bounds to be calcdata. For nonuniform bins // we already have this, but uniform with start/end/size they're still strings. - if(!nonuniformBinsX && xa.type === 'date') { - xbins = { - start: xr2c(xbins.start), - end: xr2c(xbins.end), - size: xbins.size - }; - } - if(!nonuniformBinsY && ya.type === 'date') { - ybins = { - start: yr2c(ybins.start), - end: yr2c(ybins.end), - size: ybins.size - }; - } - + if(!nonuniformBinsX && xa.type === 'date') xbins = binsToCalc(xr2c, xbins); + if(!nonuniformBinsY && ya.type === 'date') ybins = binsToCalc(yr2c, ybins); // put data into bins + var uniqueValsPerX = true; + var uniqueValsPerY = true; + var xVals = new Array(nx); + var yVals = new Array(ny); + var xGapLow = Infinity; + var xGapHigh = Infinity; + var yGapLow = Infinity; + var yGapHigh = Infinity; for(i = 0; i < serieslen; i++) { - n = Lib.findBin(x[i], xbins); - m = Lib.findBin(y[i], ybins); + var xi = x[i]; + var yi = y[i]; + n = Lib.findBin(xi, xbins); + m = Lib.findBin(yi, ybins); if(n >= 0 && n < nx && m >= 0 && m < ny) { total += binfunc(n, i, z[m], rawCounterData, counts[m]); + inputPoints[m][n].push(i); + + if(uniqueValsPerX) { + if(xVals[n] === undefined) xVals[n] = xi; + else if(xVals[n] !== xi) uniqueValsPerX = false; + } + if(uniqueValsPerY) { + if(yVals[n] === undefined) yVals[n] = yi; + else if(yVals[n] !== yi) uniqueValsPerY = false; + } + + xGapLow = Math.min(xGapLow, xi - xEdges[n]); + xGapHigh = Math.min(xGapHigh, xEdges[n + 1] - xi); + yGapLow = Math.min(yGapLow, yi - yEdges[m]); + yGapHigh = Math.min(yGapHigh, yEdges[m + 1] - yi); } } // normalize, if needed @@ -199,11 +170,77 @@ module.exports = function calc(gd, trace) { return { x: x, + xRanges: getRanges(xEdges, uniqueValsPerX && xVals, xGapLow, xGapHigh, xa, xcalendar), x0: x0, dx: dx, y: y, + yRanges: getRanges(yEdges, uniqueValsPerY && yVals, yGapLow, yGapHigh, ya, ycalendar), y0: y0, dy: dy, - z: z + z: z, + pts: inputPoints }; }; + +function cleanAndAutobin(trace, axLetter, data, ax, r2c, c2r, calendar) { + var binSpecAttr = axLetter + 'bins'; + var autoBinAttr = 'autobin' + axLetter; + var binSpec = trace[binSpecAttr]; + + cleanBins(trace, ax, axLetter); + + if(trace[autoBinAttr] || !binSpec || binSpec.start === null || binSpec.end === null) { + binSpec = Axes.autoBin(data, ax, trace['nbins' + axLetter], '2d', calendar); + if(trace.type === 'histogram2dcontour') { + // the "true" last 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)); + } + + // 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]; + } +} + +function makeIncrements(len, bins, dv, nonuniform) { + var out = new Array(len); + var i; + if(nonuniform) { + for(i = 0; i < len; i++) out[i] = 1 / (bins[i + 1] - bins[i]); + } + else { + var inc = 1 / dv; + for(i = 0; i < len; i++) out[i] = inc; + } + return out; +} + +function binsToCalc(r2c, bins) { + return { + start: r2c(bins.start), + end: r2c(bins.end), + size: bins.size + }; +} + +function getRanges(edges, uniqueVals, gapLow, gapHigh, ax, calendar) { + var i; + var len = edges.length - 1; + var out = new Array(len); + if(uniqueVals) { + for(i = 0; i < len; i++) out[i] = [uniqueVals[i], uniqueVals[i]]; + } + else { + var roundFn = getBinSpanLabelRound(gapLow, gapHigh, edges, ax, calendar); + for(i = 0; i < len; i++) out[i] = [roundFn(edges[i]), roundFn(edges[i + 1], true)]; + } + return out; +} diff --git a/src/traces/histogram2d/hover.js b/src/traces/histogram2d/hover.js new file mode 100644 index 00000000000..87972380b61 --- /dev/null +++ b/src/traces/histogram2d/hover.js @@ -0,0 +1,33 @@ +/** +* Copyright 2012-2017, 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 heatmapHover = require('../heatmap/hover'); +var hoverLabelText = require('../../plots/cartesian/axes').hoverLabelText; + +module.exports = function hoverPoints(pointData, xval, yval, hovermode, contour) { + var pts = heatmapHover(pointData, xval, yval, hovermode, contour); + + if(!pts) return; + + pointData = pts[0]; + var indices = pointData.index; + var ny = indices[0]; + var nx = indices[1]; + var cd0 = pointData.cd[0]; + var xRange = cd0.xRanges[nx]; + var yRange = cd0.yRanges[ny]; + + pointData.xLabel = hoverLabelText(pointData.xa, xRange[0], xRange[1]); + pointData.yLabel = hoverLabelText(pointData.ya, yRange[0], yRange[1]); + pointData.pts = cd0.pts[ny][nx]; + + return pts; +}; diff --git a/src/traces/histogram2d/index.js b/src/traces/histogram2d/index.js index fb0975bf58e..365ada16057 100644 --- a/src/traces/histogram2d/index.js +++ b/src/traces/histogram2d/index.js @@ -17,7 +17,8 @@ Histogram2D.calc = require('../heatmap/calc'); Histogram2D.plot = require('../heatmap/plot'); Histogram2D.colorbar = require('../heatmap/colorbar'); Histogram2D.style = require('../heatmap/style'); -Histogram2D.hoverPoints = require('../heatmap/hover'); +Histogram2D.hoverPoints = require('./hover'); +Histogram2D.eventData = require('../histogram/event_data'); Histogram2D.moduleType = 'trace'; Histogram2D.name = 'histogram2d'; diff --git a/test/jasmine/tests/histogram_test.js b/test/jasmine/tests/histogram_test.js index 01f992468f3..e9ae22bc786 100644 --- a/test/jasmine/tests/histogram_test.js +++ b/test/jasmine/tests/histogram_test.js @@ -1,9 +1,11 @@ var Plotly = require('@lib/index'); var Plots = require('@src/plots/plots'); var Lib = require('@src/lib'); +var setConvert = require('@src/plots/cartesian/set_convert'); var supplyDefaults = require('@src/traces/histogram/defaults'); var calc = require('@src/traces/histogram/calc'); +var getBinSpanLabelRound = require('@src/traces/histogram/bin_label_vals'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); @@ -197,13 +199,17 @@ describe('Test histogram', function() { // bars. Now that we have explicit per-bar positioning, perhaps // we should fill the space, rather than insisting on equal-width // bars? + var d70 = Date.UTC(1970, 0, 1); + var d71 = Date.UTC(1971, 0, 1); + var d72 = Date.UTC(1972, 0, 1, 12); + var d73 = Date.UTC(1973, 0, 1); expect(out).toEqual([ // full calcdata has x and y too (and t in the first one), // but those come later from setPositions. - {b: 0, p: Date.UTC(1970, 0, 1), s: 2}, - {b: 0, p: Date.UTC(1971, 0, 1), s: 1}, - {b: 0, p: Date.UTC(1972, 0, 1, 12), s: 0}, - {b: 0, p: Date.UTC(1973, 0, 1), s: 1} + {b: 0, p: d70, s: 2, pts: [0, 1], p0: d70, p1: d70}, + {b: 0, p: d71, s: 1, pts: [2], p0: d71, p1: d71}, + {b: 0, p: d72, s: 0, pts: [], p0: d72, p1: d72}, + {b: 0, p: d73, s: 1, pts: [3], p0: d73, p1: d73} ]); // All data on exact months: shift so bin center is on (31-day months) @@ -213,11 +219,14 @@ describe('Test histogram', function() { nbinsx: 4 }); + var d70feb = Date.UTC(1970, 1, 1); + var d70mar = Date.UTC(1970, 2, 2, 12); + var d70apr = Date.UTC(1970, 3, 1); expect(out).toEqual([ - {b: 0, p: Date.UTC(1970, 0, 1), s: 2}, - {b: 0, p: Date.UTC(1970, 1, 1), s: 1}, - {b: 0, p: Date.UTC(1970, 2, 2, 12), s: 0}, - {b: 0, p: Date.UTC(1970, 3, 1), s: 1} + {b: 0, p: d70, s: 2, pts: [0, 1], p0: d70, p1: d70}, + {b: 0, p: d70feb, s: 1, pts: [2], p0: d70feb, p1: d70feb}, + {b: 0, p: d70mar, s: 0, pts: [], p0: d70mar, p1: d70mar}, + {b: 0, p: d70apr, s: 1, pts: [3], p0: d70apr, p1: d70apr} ]); // data on exact days: shift so each bin goes from noon to noon @@ -229,13 +238,21 @@ describe('Test histogram', function() { nbinsx: 4 }); + // this is dumb - but some of the `p0` values are `-0` which doesn't match `0` + // even though -0 === 0 + out.forEach(function(cdi) { + Object.keys(cdi).forEach(function(key) { + if(cdi[key] === 0) cdi[key] = 0; + }); + }); + expect(out).toEqual([ // dec 31 12:00 -> jan 31 12:00, middle is jan 16 - {b: 0, p: Date.UTC(1970, 0, 16), s: 2}, + {b: 0, p: Date.UTC(1970, 0, 16), s: 2, pts: [0, 1], p0: Date.UTC(1970, 0, 1), p1: Date.UTC(1970, 0, 31)}, // jan 31 12:00 -> feb 28 12:00, middle is feb 14 12:00 - {b: 0, p: Date.UTC(1970, 1, 14, 12), s: 1}, - {b: 0, p: Date.UTC(1970, 2, 16), s: 0}, - {b: 0, p: Date.UTC(1970, 3, 15, 12), s: 1} + {b: 0, p: Date.UTC(1970, 1, 14, 12), s: 1, pts: [2], p0: Date.UTC(1970, 1, 1), p1: Date.UTC(1970, 1, 28)}, + {b: 0, p: Date.UTC(1970, 2, 16), s: 0, pts: [], p0: Date.UTC(1970, 2, 1), p1: Date.UTC(1970, 2, 31)}, + {b: 0, p: Date.UTC(1970, 3, 15, 12), s: 1, pts: [3], p0: Date.UTC(1970, 3, 1), p1: Date.UTC(1970, 3, 30)} ]); }); @@ -251,10 +268,10 @@ describe('Test histogram', function() { x3 = x2 + oneDay; expect(out).toEqual([ - {b: 0, p: x0, s: 2}, - {b: 0, p: x1, s: 1}, - {b: 0, p: x2, s: 0}, - {b: 0, p: x3, s: 1} + {b: 0, p: x0, s: 2, pts: [0, 1], p0: x0, p1: x0}, + {b: 0, p: x1, s: 1, pts: [2], p0: x1, p1: x1}, + {b: 0, p: x2, s: 0, pts: [], p0: x2, p1: x2}, + {b: 0, p: x3, s: 1, pts: [3], p0: x3, p1: x3} ]); }); @@ -278,7 +295,7 @@ describe('Test histogram', function() { }); expect(out).toEqual([ - {b: 0, p: 3, s: 3, width1: 2} + {b: 0, p: 3, s: 3, width1: 2, pts: [0, 1, 2], p0: 2, p1: 3.9} ]); }); @@ -291,7 +308,7 @@ describe('Test histogram', function() { }); expect(out).toEqual([ - {b: 0, p: 1.1, s: 3, width1: 0.5} + {b: 0, p: 1.1, s: 3, width1: 0.5, pts: [0, 1, 2], p0: 1.1, p1: 1.1} ]); }); @@ -304,7 +321,7 @@ describe('Test histogram', function() { }); expect(out).toEqual([ - {b: 0, p: 17, s: 2, width1: 2} + {b: 0, p: 17, s: 2, width1: 2, pts: [2, 4], p0: 17, p1: 17} ]); }); @@ -317,7 +334,7 @@ describe('Test histogram', function() { }); expect(out).toEqual([ - {b: 0, p: 13, s: 2, width1: 8} + {b: 0, p: 13, s: 2, width1: 8, pts: [1, 3], p0: 13, p1: 13} ]); }); @@ -329,8 +346,9 @@ describe('Test histogram', function() { barmode: 'overlay' }); + var p = 1296691200000; expect(out).toEqual([ - {b: 0, p: 1296691200000, s: 2, width1: 2 * 24 * 3600 * 1000} + {b: 0, p: p, s: 2, width1: 2 * 24 * 3600 * 1000, pts: [1, 3], p0: p, p1: p} ]); }); @@ -343,7 +361,7 @@ describe('Test histogram', function() { }); expect(out).toEqual([ - {b: 0, p: 97, s: 2, width1: 1} + {b: 0, p: 97, s: 2, width1: 1, pts: [1, 3], p0: 97, p1: 97} ]); }); @@ -423,7 +441,7 @@ describe('Test histogram', function() { var trace4 = {x: [1, 1.2, 1.4, 1.6], yaxis: 'y2'}; expect(calcPositions(trace1, [trace2, trace3, trace4])).toEqual([1, 2, 3, 4]); - expect(calcPositions(trace3)).toBeCloseToArray([0.9, 1.1, 1.3], 5); + expect(calcPositions(trace3)).toBeCloseToArray([1.1, 1.3], 5); }); describe('cumulative distribution functions', function() { @@ -435,13 +453,14 @@ describe('Test histogram', function() { it('makes the right base histogram', function() { var baseOut = _calc(base); expect(baseOut).toEqual([ - {b: 0, p: 2, s: 1}, - {b: 0, p: 7, s: 2}, - {b: 0, p: 12, s: 3}, - {b: 0, p: 17, s: 4}, + {b: 0, p: 2, s: 1, pts: [0], p0: 0, p1: 0}, + {b: 0, p: 7, s: 2, pts: [1, 4], p0: 5, p1: 5}, + {b: 0, p: 12, s: 3, pts: [2, 5, 7], p0: 10, p1: 10}, + {b: 0, p: 17, s: 4, pts: [3, 6, 8, 9], p0: 15, p1: 15}, ]); }); + // p0, p1, and pts have been omitted from CDFs for now var CDFs = [ {p: [2, 7, 12, 17], s: [1, 3, 6, 10]}, { @@ -647,3 +666,207 @@ describe('Test histogram', function() { }); }); }); + +describe('getBinSpanLabelRound', function() { + function _test(leftGap, rightGap, edges, calendar, expected) { + var ax = {type: 'not date'}; + // only date axes have any different treatment here. We could explicitly + // test category + if(calendar) { + ax = {type: 'date', calendar: 'gregorian', range: [0, 1e7]}; + setConvert(ax); + } + + var roundFn = getBinSpanLabelRound(leftGap, rightGap, edges, ax, calendar); + + var j = 0; + var PREC = calendar ? 1 : 6; + edges.forEach(function(edge, i) { + if(i) { + expect(roundFn(edge, true)).toBeCloseTo(expected[j], PREC, 'right ' + i); + j++; + } + if(i < edges.length - 1) { + expect(roundFn(edge)).toBeCloseTo(expected[j], PREC, 'left ' + i); + j++; + } + }); + } + + it('works when the bin edges are round numbers and data are "continuous"', function() { + _test(0.05, 0.3, [0, 2, 4, 6], false, [0, 1.9, 2, 3.9, 4, 5.9]); + _test(0, 0.1, [0, 1, 2], false, [0, 0.9, 1, 1.9]); + _test(0, 0.001, [0, 1, 2], false, [0, 0.999, 1, 1.999]); + _test(0.1, 0.1, [115, 125, 135], false, [115, 124.9, 125, 134.9]); + _test(0.1, 0.01, [115, 125, 135], false, [115, 124.99, 125, 134.99]); + _test(10, 100, [5000, 6000, 7000], false, [5000, 5900, 6000, 6900]); + + // too small of a right gap: stop disambiguating data at the edge + _test(0, 0.0009, [0, 1, 2], false, [0, 1, 1, 2]); + _test(0.1, 0.009, [115, 125, 135], false, [115, 125, 125, 135]); + }); + + it('works when the bins are shifted to be less round than the data', function() { + // integer or category data look like this - though categories don't even + // get here normally, unless you explicitly ask to bin multiple categories + // together, because uniqueValsPerBin will be true + _test(0.5, 0.5, [-0.5, 4.5, 9.5], false, [0, 4, 5, 9]); + + _test(0.013, 0.087, [-0.013, 0.987, 1.987], false, [0, 0.9, 1, 1.9]); + _test(500, 500, [4500, 9500, 14500], false, [5000, 9000, 10000, 14000]); + }); + + var jan17 = Date.UTC(2017, 0, 1); + var feb17 = Date.UTC(2017, 1, 1); + var mar17 = Date.UTC(2017, 2, 1); + var jan18 = Date.UTC(2018, 0, 1); + var jan19 = Date.UTC(2019, 0, 1); + var j2116 = Date.UTC(2116, 0, 1); + var j2117 = Date.UTC(2117, 0, 1); + var j2216 = Date.UTC(2216, 0, 1); + var j2217 = Date.UTC(2217, 0, 1); + var sec = 1000; + var min = 60 * sec; + var hr = 60 * min; + var day = 24 * hr; + + it('rounds dates to full fields (if larger than seconds) - round bin edges case', function() { + // sub-second & 1-second resolution + _test(0, 0.1, [jan17, jan17 + 1, jan17 + 2], 'gregorian', + [jan17, jan17 + 0.9, jan17 + 1, jan17 + 1.9]); + _test(1, 3, [jan17, jan17 + 20, jan17 + 40], 'gregorian', + [jan17, jan17 + 19, jan17 + 20, jan17 + 39]); + _test(5, 35, [jan17, jan17 + 1000, jan17 + 2000], 'gregorian', + [jan17, jan17 + 990, jan17 + 1000, jan17 + 1990]); + _test(0, 100, [jan17, jan17 + 20000, jan17 + 40000], 'gregorian', + [jan17, jan17 + 19900, jan17 + 20000, jan17 + 39900]); + _test(100, 2000, [jan17, jan17 + 10000, jan17 + 20000], 'gregorian', + [jan17, jan17 + 9000, jan17 + 10000, jan17 + 19000]); + + // > second, only go to the next full field + // 30 sec gap - still show seconds + _test(0, 30 * sec, [jan17, jan17 + 5 * min, jan17 + 10 * min], 'gregorian', + [jan17, jan17 + 5 * min - sec, jan17 + 5 * min, jan17 + 10 * min - sec]); + // 1-minute gap round to minutes + _test(10 * sec, min, [jan17, jan17 + 5 * min, jan17 + 10 * min], 'gregorian', + [jan17, jan17 + 4 * min, jan17 + 5 * min, jan17 + 9 * min]); + // 30-minute gap - still show minutes + _test(0, 30 * min, [jan17, jan17 + day, jan17 + 2 * day], 'gregorian', + [jan17, jan17 + day - min, jan17 + day, jan17 + 2 * day - min]); + // 1-hour gap - round to hours + _test(0, hr, [jan17, jan17 + day, jan17 + 2 * day], 'gregorian', + [jan17, jan17 + day - hr, jan17 + day, jan17 + 2 * day - hr]); + // 12-hour gap - still hours + _test(0, 12 * hr, [jan17, feb17, mar17], 'gregorian', + [jan17, feb17 - hr, feb17, mar17 - hr]); + // 1-day gap - round to days + _test(0, day, [jan17, feb17, mar17], 'gregorian', + [jan17, feb17 - day, feb17, mar17 - day]); + // 15-day gap - still days + _test(0, 15 * day, [jan17, jan18, jan19], 'gregorian', + [jan17, jan18 - day, jan18, jan19 - day]); + // 28-day gap STILL gets days - in principle this might happen with data + // that's actually monthly, if the bins edges fall on different months + // (ie not full years) but that's a very weird edge case so I'll ignore it! + _test(0, 28 * day, [jan17, jan18, jan19], 'gregorian', + [jan17, jan18 - day, jan18, jan19 - day]); + // 31-day gap - months + _test(0, 31 * day, [jan17, jan18, jan19], 'gregorian', + [jan17, Date.UTC(2017, 11, 1), jan18, Date.UTC(2018, 11, 1)]); + // 365-day gap - years have enough buffer to handle leap vs nonleap years + _test(0, 365 * day, [jan17, j2117, j2217], 'gregorian', + [jan17, j2116, j2117, j2216]); + // no bigger rounding than years + _test(0, 40 * 365 * day, [jan17, j2117, j2217], 'gregorian', + [jan17, j2116, j2117, j2216]); + }); + + it('rounds dates to full fields (if larger than seconds) - round data case', function() { + // sub-second & 1-second resolution + _test(0.05, 0.05, [jan17 - 0.05, jan17 + 0.95, jan17 + 1.95], 'gregorian', + [jan17, jan17 + 0.9, jan17 + 1, jan17 + 1.9]); + _test(0.5, 3.5, [jan17 - 0.5, jan17 + 19.5, jan17 + 39.5], 'gregorian', + [jan17, jan17 + 19, jan17 + 20, jan17 + 39]); + _test(5, 35, [jan17 - 5, jan17 + 995, jan17 + 1995], 'gregorian', + [jan17, jan17 + 990, jan17 + 1000, jan17 + 1990]); + _test(50, 50, [jan17 - 50, jan17 + 19950, jan17 + 39950], 'gregorian', + [jan17, jan17 + 19900, jan17 + 20000, jan17 + 39900]); + _test(500, 2500, [jan17 - 500, jan17 + 9500, jan17 + 19500], 'gregorian', + [jan17, jan17 + 9000, jan17 + 10000, jan17 + 19000]); + + // > second, only go to the next full field + // 30 sec gap - still show seconds + _test(15 * sec, 15 * sec, [jan17 - 15 * sec, jan17 + 5 * min - 15 * sec, jan17 + 10 * min - 15 * sec], 'gregorian', + [jan17 - 15 * sec, jan17 + 5 * min - 16 * sec, jan17 + 5 * min - 15 * sec, jan17 + 10 * min - 16 * sec]); + // 1-minute gap round to minutes + _test(30 * sec, 30 * sec, [jan17 - 30 * sec, jan17 + 5 * min - 30 * sec, jan17 + 10 * min - 30 * sec], 'gregorian', + [jan17, jan17 + 4 * min, jan17 + 5 * min, jan17 + 9 * min]); + // 30-minute gap - still show minutes + _test(15 * min, 15 * min, [jan17 - 15 * min, jan17 + day - 15 * min, jan17 + 2 * day - 15 * min], 'gregorian', + [jan17 - 15 * min, jan17 + day - 16 * min, jan17 + day - 15 * min, jan17 + 2 * day - 16 * min]); + // 1-hour gap - round to hours + _test(30 * min, 30 * min, [jan17 - 30 * min, jan17 + day - 30 * min, jan17 + 2 * day - 30 * min], 'gregorian', + [jan17, jan17 + day - hr, jan17 + day, jan17 + 2 * day - hr]); + // 12-hour gap - still hours + _test(6 * hr, 6 * hr, [jan17 - 6 * hr, feb17 - 6 * hr, mar17 - 6 * hr], 'gregorian', + [jan17 - 6 * hr, feb17 - 7 * hr, feb17 - 6 * hr, mar17 - 7 * hr]); + // 1-day gap - round to days + _test(12 * hr, 12 * hr, [jan17 - 12 * hr, feb17 - 12 * hr, mar17 - 12 * hr], 'gregorian', + [jan17, feb17 - day, feb17, mar17 - day]); + // 15-day gap - still days + _test(7 * day, 8 * day, [jan17 - 7 * day, jan18 - 7 * day, jan19 - 7 * day], 'gregorian', + [jan17 - 7 * day, jan18 - 8 * day, jan18 - 7 * day, jan19 - 8 * day]); + // 28-day gap STILL gets days - in principle this might happen with data + // that's actually monthly, if the bins edges fall on different months + // (ie not full years) but that's a very weird edge case so I'll ignore it! + _test(14 * day, 14 * day, [jan17 - 14 * day, jan18 - 14 * day, jan19 - 14 * day], 'gregorian', + [jan17 - 14 * day, jan18 - 15 * day, jan18 - 14 * day, jan19 - 15 * day]); + // 31-day gap - months + _test(15 * day, 16 * day, [jan17 - 15 * day, jan18 - 15 * day, jan19 - 15 * day], 'gregorian', + [jan17, Date.UTC(2017, 11, 1), jan18, Date.UTC(2018, 11, 1)]); + // 365-day gap - years have enough buffer to handle leap vs nonleap years + _test(182 * day, 183 * day, [jan17 - 182 * day, j2117 - 182 * day, j2217 - 182 * day], 'gregorian', + [jan17, j2116, j2117, j2216]); + // no bigger rounding than years + _test(20 * 365 * day, 20 * 365 * day, [jan17, j2117, j2217], 'gregorian', + [jan17, j2116, j2117, j2216]); + }); + + it('rounds (mostly) correctly when using world calendars', function() { + var cn = 'chinese'; + var cn8 = Lib.dateTime2ms('1995-08-01', cn); + var cn8i = Lib.dateTime2ms('1995-08i-01', cn); + var cn9 = Lib.dateTime2ms('1995-09-01', cn); + + var cn1_00 = Lib.dateTime2ms('2000-01-01', cn); + var cn1_01 = Lib.dateTime2ms('2001-01-01', cn); + var cn1_02 = Lib.dateTime2ms('2002-01-01', cn); + var cn1_10 = Lib.dateTime2ms('2010-01-01', cn); + var cn1_20 = Lib.dateTime2ms('2020-01-01', cn); + + _test(100, 2000, [cn8i, cn8i + 10000, cn8i + 20000], cn, + [cn8i, cn8i + 9000, cn8i + 10000, cn8i + 19000]); + _test(500, 2500, [cn8i - 500, cn8i + 9500, cn8i + 19500], cn, + [cn8i, cn8i + 9000, cn8i + 10000, cn8i + 19000]); + + _test(0, day, [cn8, cn8i, cn9], cn, + [cn8, cn8i - day, cn8i, cn9 - day]); + _test(12 * hr, 12 * hr, [cn8 - 12 * hr, cn8i - 12 * hr, cn9 - 12 * hr], cn, + [cn8, cn8i - day, cn8i, cn9 - day]); + + _test(0, 28 * day, [cn1_00, cn1_01, cn1_02], cn, + [cn1_00, Lib.dateTime2ms('2000-12-01', cn), cn1_01, Lib.dateTime2ms('2001-12-01', cn)]); + _test(14 * day, 14 * day, [cn1_00 - 14 * day, cn1_01 - 14 * day, cn1_02 - 14 * day], cn, + [cn1_00, Lib.dateTime2ms('2000-12-01', cn), cn1_01, Lib.dateTime2ms('2001-12-01', cn)]); + + _test(0, 353 * day, [cn1_00, cn1_10, cn1_20], cn, + [cn1_00, Lib.dateTime2ms('2009-01-01', cn), cn1_10, Lib.dateTime2ms('2019-01-01', cn)]); + // occasionally we give extra precision for world dates (month when it should be year + // or day when it should be month). That's better than doing the opposite... not going + // to fix now, too many edge cases, better not to complicate the logic for them all. + _test(176 * day, 177 * day, [cn1_00 - 176 * day, cn1_10 - 176 * day, cn1_20 - 176 * day], cn, [ + Lib.dateTime2ms('1999-08-01', cn), Lib.dateTime2ms('2009-07-01', cn), + Lib.dateTime2ms('2009-08-01', cn), Lib.dateTime2ms('2019-07-01', cn) + ]); + }); +}); diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index b6be796bdbe..688348baa1d 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -458,12 +458,22 @@ describe('hover info', function() { }); }); - describe('\'hover info for x/y/z traces', function() { - function _hover(gd, xpx, ypx) { - Fx.hover(gd, { xpx: xpx, ypx: ypx }, 'xy'); - Lib.clearThrottle(); - } + function _hover(gd, xpx, ypx) { + Fx.hover(gd, { xpx: xpx, ypx: ypx }, 'xy'); + Lib.clearThrottle(); + } + + function _hoverNatural(gd, xpx, ypx) { + var gdBB = gd.getBoundingClientRect(); + var dragger = gd.querySelector('.nsewdrag'); + var clientX = xpx + gdBB.left + gd._fullLayout._size.l; + var clientY = ypx + gdBB.top + gd._fullLayout._size.t; + + Fx.hover(gd, { clientX: clientX, clientY: clientY, target: dragger}, 'xy'); + Lib.clearThrottle(); + } + describe('\'hover info for x/y/z traces', function() { it('should display correct label content', function(done) { var gd = createGraphDiv(); @@ -501,6 +511,173 @@ describe('hover info', function() { }); }); + describe('hover info for negative data on a log axis', function() { + it('shows negative data even though it is infinitely off-screen', function(done) { + var gd = createGraphDiv(); + + Plotly.plot(gd, [{x: [1, 2, 3], y: [1, -5, 10]}], { + yaxis: {type: 'log'}, + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0} + }) + .then(function() { + _hover(gd, 250, 200); + assertHoverLabelContent({ + nums: '\u22125', // unicode minus + axis: '2' + }); + }) + .catch(fail) + .then(done); + }); + }); + + describe('histogram hover info', function() { + it('shows the data range when bins have multiple values', function(done) { + var gd = createGraphDiv(); + var pts; + + Plotly.plot(gd, [{ + x: [0, 2, 3, 4, 5, 6, 7], + xbins: {start: -0.5, end: 8.5, size: 3}, + type: 'histogram' + }], { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0} + }) + .then(function() { + gd.on('plotly_hover', function(e) { pts = e.points; }); + + _hoverNatural(gd, 250, 200); + assertHoverLabelContent({ + nums: '3', + axis: '3 - 5' + }); + }) + .then(function() { + expect(pts.length).toBe(1); + var pt = pts[0]; + + expect(pt.curveNumber).toBe(0); + expect(pt.binNumber).toBe(1); + expect(pt.pointNumbers).toEqual([2, 3, 4]); + expect(pt.x).toBe(4); + expect(pt.y).toBe(3); + expect(pt.data).toBe(gd.data[0]); + expect(pt.fullData).toBe(gd._fullData[0]); + expect(pt.xaxis).toBe(gd._fullLayout.xaxis); + expect(pt.yaxis).toBe(gd._fullLayout.yaxis); + }) + .catch(fail) + .then(done); + }); + + it('shows the exact data when bins have single values', function(done) { + var gd = createGraphDiv(); + + Plotly.plot(gd, [{ + // even though the data aren't regularly spaced, each bin only has + // one data value in it so we see exactly that value + x: [0, 0, 3.3, 3.3, 3.3, 7, 7], + xbins: {start: -0.5, end: 8.5, size: 3}, + type: 'histogram' + }], { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0} + }) + .then(function() { + _hover(gd, 250, 200); + assertHoverLabelContent({ + nums: '3', + axis: '3.3' + }); + }) + .catch(fail) + .then(done); + }); + + it('will show a category range if you ask nicely', function(done) { + var gd = createGraphDiv(); + + Plotly.plot(gd, [{ + // even though the data aren't regularly spaced, each bin only has + // one data value in it so we see exactly that value + x: [ + 'bread', 'cheese', 'artichokes', 'soup', 'beans', 'nuts', + 'pizza', 'potatoes', 'burgers', 'beans', 'beans', 'beans' + ], + xbins: {start: -0.5, end: 8.5, size: 3}, + type: 'histogram' + }], { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0} + }) + .then(function() { + _hover(gd, 250, 200); + assertHoverLabelContent({ + nums: '6', + axis: 'soup - nuts' + }); + }) + .catch(fail) + .then(done); + }); + }); + + describe('histogram2d hover info', function() { + it('shows the data range when bins have multiple values', function(done) { + var gd = createGraphDiv(); + + Plotly.plot(gd, [{ + x: [0, 2, 3, 4, 5, 6, 7], + y: [1, 3, 4, 5, 6, 7, 8], + xbins: {start: -0.5, end: 8.5, size: 3}, + ybins: {start: 0.5, end: 9.5, size: 3}, + type: 'histogram2d' + }], { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0} + }) + .then(function() { + _hover(gd, 250, 200); + assertHoverLabelContent({ + nums: 'x: 3 - 5\ny: 4 - 6\nz: 3' + }); + }) + .catch(fail) + .then(done); + }); + + it('shows the exact data when bins have single values', function(done) { + var gd = createGraphDiv(); + + Plotly.plot(gd, [{ + x: [0, 0, 3.3, 3.3, 3.3, 7, 7], + y: [2, 2, 4.2, 4.2, 4.2, 8.8, 8.8], + xbins: {start: -0.5, end: 8.5, size: 3}, + ybins: {start: 0.5, end: 9.5, size: 3}, + type: 'histogram2d' + }], { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0} + }) + .then(function() { + _hover(gd, 250, 200); + assertHoverLabelContent({ + nums: 'x: 3.3\ny: 4.2\nz: 3' + }); + }) + .catch(fail) + .then(done); + }); + }); + describe('hoverformat', function() { var data = [{ x: [1, 2, 3],