diff --git a/src/components/errorbars/calc.js b/src/components/errorbars/calc.js index 5340cdc6b27..56742248010 100644 --- a/src/components/errorbars/calc.js +++ b/src/components/errorbars/calc.js @@ -43,12 +43,29 @@ function calcOneAxis(calcTrace, trace, axis, coord) { var computeError = makeComputeError(opts); for(var i = 0; i < calcTrace.length; i++) { - var calcPt = calcTrace[i], - calcCoord = calcPt[coord]; + var calcPt = calcTrace[i]; + + var iIn = calcPt.i; + + // for types that don't include `i` in each calcdata point + if(iIn === undefined) iIn = i; + + // for stacked area inserted points + // TODO: errorbars have been tested cursorily with stacked area, + // but not thoroughly. It's not even really clear what you want to do: + // Should it just be calculated based on that trace's size data? + // Should you add errors from below in quadrature? + // And what about normalization, where in principle the errors shrink + // again when you get up to the top end? + // One option would be to forbid errorbars with stacking until we + // decide how to handle these questions. + else if(iIn === null) continue; + + var calcCoord = calcPt[coord]; if(!isNumeric(axis.c2l(calcCoord))) continue; - var errors = computeError(calcCoord, i); + var errors = computeError(calcCoord, iIn); if(isNumeric(errors[0]) && isNumeric(errors[1])) { var shoe = calcPt[coord + 's'] = calcCoord - errors[0], hat = calcPt[coord + 'h'] = calcCoord + errors[1]; diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index ff8175c69c5..4c187ae1fa9 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -135,9 +135,9 @@ exports.loneHover = function loneHover(hoverItem, opts) { index: 0 }; - var container3 = d3.select(opts.container), - outerContainer3 = opts.outerContainer ? - d3.select(opts.outerContainer) : container3; + var container3 = d3.select(opts.container); + var outerContainer3 = opts.outerContainer ? + d3.select(opts.outerContainer) : container3; var fullOpts = { hovermode: 'closest', @@ -216,37 +216,26 @@ function _hover(gd, evt, subplot, noHoverEvent) { var spikedistance = fullLayout.spikedistance === -1 ? Infinity : fullLayout.spikedistance; // hoverData: the set of candidate points we've found to highlight - var hoverData = [], - - // searchData: the data to search in. Mostly this is just a copy of - // gd.calcdata, filtered to the subplot and overlays we're on - // but if a point array is supplied it will be a mapping - // of indicated curves - searchData = [], - - // [x|y]valArray: the axis values of the hover event - // mapped onto each of the currently selected overlaid subplots - xvalArray, - yvalArray, - - // used in loops - itemnum, - curvenum, - cd, - trace, - subplotId, - subploti, - mode, - xval, - yval, - pointData, - closedataPreviousLength, - - // spikePoints: the set of candidate points we've found to draw spikes to - spikePoints = { - hLinePoint: null, - vLinePoint: null - }; + var hoverData = []; + + // searchData: the data to search in. Mostly this is just a copy of + // gd.calcdata, filtered to the subplot and overlays we're on + // but if a point array is supplied it will be a mapping + // of indicated curves + var searchData = []; + + // [x|y]valArray: the axis values of the hover event + // mapped onto each of the currently selected overlaid subplots + var xvalArray, yvalArray; + + var itemnum, curvenum, cd, trace, subplotId, subploti, mode, + xval, yval, pointData, closedataPreviousLength; + + // spikePoints: the set of candidate points we've found to draw spikes to + var spikePoints = { + hLinePoint: null, + vLinePoint: null + }; // Figure out what we're hovering on: // mouse location or user-supplied data @@ -273,8 +262,8 @@ function _hover(gd, evt, subplot, noHoverEvent) { // [x|y]px: the pixels (from top left) of the mouse location // on the currently selected plot area // add pointerX|Y property for drawing the spikes in spikesnap 'cursor' situation - var hasUserCalledHover = !evt.target, - xpx, ypx; + var hasUserCalledHover = !evt.target; + var xpx, ypx; if(hasUserCalledHover) { if('xpx' in evt) xpx = evt.xpx; @@ -576,8 +565,8 @@ function _hover(gd, evt, subplot, noHoverEvent) { hoverData.sort(function(d1, d2) { return d1.distance - d2.distance; }); // lastly, emit custom hover/unhover events - var oldhoverdata = gd._hoverdata, - newhoverdata = []; + var oldhoverdata = gd._hoverdata; + var newhoverdata = []; // pull out just the data that's useful to // other people and send it to the event @@ -677,8 +666,8 @@ function createHoverText(hoverData, opts, gd) { // all hover traces hoverinfo must contain the hovermode // to have common labels if(showCommonLabel) { - var i, traceHoverinfo; var allHaveZ = true; + var i, traceHoverinfo; for(i = 0; i < hoverData.length; i++) { if(allHaveZ && hoverData[i].zLabel === undefined) allHaveZ = false; @@ -802,9 +791,9 @@ function createHoverText(hoverData, opts, gd) { // then put the text in, position the pointer to the data, // and figure out sizes hoverLabels.each(function(d) { - var g = d3.select(this).attr('transform', ''), - name = '', - text = ''; + var g = d3.select(this).attr('transform', ''); + var name = ''; + var text = ''; // combine possible non-opaque trace color with bgColor var baseColor = Color.opacity(d.color) ? d.color : Color.defaultLine; @@ -872,8 +861,8 @@ function createHoverText(hoverData, opts, gd) { .call(svgTextUtils.positionText, 0, 0) .call(svgTextUtils.convertToTspans, gd); - var tx2 = g.select('text.name'), - tx2width = 0; + var tx2 = g.select('text.name'); + var tx2width = 0; // secondary label for non-empty 'name' if(name && name !== text) { @@ -897,14 +886,13 @@ function createHoverText(hoverData, opts, gd) { fill: traceColor, stroke: contrastColor }); - var tbb = tx.node().getBoundingClientRect(), - htx = d.xa._offset + (d.x0 + d.x1) / 2, - hty = d.ya._offset + (d.y0 + d.y1) / 2, - dx = Math.abs(d.x1 - d.x0), - dy = Math.abs(d.y1 - d.y0), - txTotalWidth = tbb.width + HOVERARROWSIZE + HOVERTEXTPAD + tx2width, - anchorStartOK, - anchorEndOK; + var tbb = tx.node().getBoundingClientRect(); + var htx = d.xa._offset + (d.x0 + d.x1) / 2; + var hty = d.ya._offset + (d.y0 + d.y1) / 2; + var dx = Math.abs(d.x1 - d.x0); + var dy = Math.abs(d.y1 - d.y0); + var txTotalWidth = tbb.width + HOVERARROWSIZE + HOVERTEXTPAD + tx2width; + var anchorStartOK, anchorEndOK; d.ty0 = outerTop - tbb.top; d.bx = tbb.width + 2 * HOVERTEXTPAD; @@ -961,33 +949,41 @@ function createHoverText(hoverData, opts, gd) { // the other, though it hardly matters - there's just too much // information then. function hoverAvoidOverlaps(hoverData, ax, fullLayout) { - var nummoves = 0, - - // make groups of touching points - pointgroups = hoverData - .map(function(d, i) { - var axis = d[ax]; - return [{ - i: i, - dp: 0, - pos: d.pos, - posref: d.posref, - size: d.by * (axis._id.charAt(0) === 'x' ? YFACTOR : 1) / 2, - pmin: 0, - pmax: (axis._id.charAt(0) === 'x' ? fullLayout.width : fullLayout.height) - }]; - }) - .sort(function(a, b) { return a[0].posref - b[0].posref; }), - donepositioning, - topOverlap, - bottomOverlap, - i, j, - pti, - sumdp; + var nummoves = 0; + + var axSign = 1; + + // make groups of touching points + var pointgroups = hoverData.map(function(d, i) { + var axis = d[ax]; + var axIsX = axis._id.charAt(0) === 'x'; + var rng = axis.range; + if(!i && rng && ((rng[0] > rng[1]) !== axIsX)) axSign = -1; + return [{ + i: i, + traceIndex: d.trace.index, + dp: 0, + pos: d.pos, + posref: d.posref, + size: d.by * (axIsX ? YFACTOR : 1) / 2, + pmin: 0, + pmax: (axIsX ? fullLayout.width : fullLayout.height) + }]; + }) + .sort(function(a, b) { + return (a[0].posref - b[0].posref) || + // for equal positions, sort trace indices increasing or decreasing + // depending on whether the axis is reversed or not... so stacked + // traces will generally keep their order even if one trace adds + // nothing to the stack. + (axSign * (b[0].traceIndex - a[0].traceIndex)); + }); + + var donepositioning, topOverlap, bottomOverlap, i, j, pti, sumdp; function constrainGroup(grp) { - var minPt = grp[0], - maxPt = grp[grp.length - 1]; + var minPt = grp[0]; + var maxPt = grp[grp.length - 1]; // overlap with the top - positive vals are overlaps topOverlap = minPt.pmin - minPt.pos - minPt.dp + minPt.size; @@ -1071,13 +1067,13 @@ function hoverAvoidOverlaps(hoverData, ax, fullLayout) { i = 0; while(i < pointgroups.length - 1) { // the higher (g0) and lower (g1) point group - var g0 = pointgroups[i], - g1 = pointgroups[i + 1], + var g0 = pointgroups[i]; + var g1 = pointgroups[i + 1]; - // the lowest point in the higher group (p0) - // the highest point in the lower group (p1) - p0 = g0[g0.length - 1], - p1 = g1[0]; + // the lowest point in the higher group (p0) + // the highest point in the lower group (p1) + var p0 = g0[g0.length - 1]; + var p1 = g1[0]; topOverlap = p0.pos + p0.dp + p0.size - p1.pos - p1.dp + p1.size; // Only group points that lie on the same axes @@ -1107,8 +1103,8 @@ function hoverAvoidOverlaps(hoverData, ax, fullLayout) { for(i = pointgroups.length - 1; i >= 0; i--) { var grp = pointgroups[i]; for(j = grp.length - 1; j >= 0; j--) { - var pt = grp[j], - hoverPt = hoverData[pt.i]; + var pt = grp[j]; + var hoverPt = hoverData[pt.i]; hoverPt.offset = pt.dp; hoverPt.del = pt.del; } @@ -1124,13 +1120,15 @@ function alignHoverText(hoverLabels, rotateLabels) { g.remove(); return; } - var horzSign = d.anchor === 'end' ? -1 : 1, - tx = g.select('text.nums'), - alignShift = {start: 1, end: -1, middle: 0}[d.anchor], - txx = alignShift * (HOVERARROWSIZE + HOVERTEXTPAD), - tx2x = txx + alignShift * (d.txwidth + HOVERTEXTPAD), - offsetX = 0, - offsetY = d.offset; + + var horzSign = d.anchor === 'end' ? -1 : 1; + var tx = g.select('text.nums'); + var alignShift = {start: 1, end: -1, middle: 0}[d.anchor]; + var txx = alignShift * (HOVERARROWSIZE + HOVERTEXTPAD); + var tx2x = txx + alignShift * (d.txwidth + HOVERTEXTPAD); + var offsetX = 0; + var offsetY = d.offset; + if(d.anchor === 'middle') { txx -= d.tx2width / 2; tx2x += d.txwidth / 2 + HOVERTEXTPAD; @@ -1266,12 +1264,11 @@ function createSpikelines(closestPoints, opts) { var container = opts.container; var fullLayout = opts.fullLayout; var evt = opts.event; - var xa, - ya; - var showY = !!closestPoints.hLinePoint; var showX = !!closestPoints.vLinePoint; + var xa, ya; + // Remove old spikeline items container.selectAll('.spikeline').remove(); @@ -1281,9 +1278,9 @@ function createSpikelines(closestPoints, opts) { // Horizontal line (to y-axis) if(showY) { - var hLinePoint = closestPoints.hLinePoint, - hLinePointX, - hLinePointY; + var hLinePoint = closestPoints.hLinePoint; + var hLinePointX, hLinePointY; + xa = hLinePoint && hLinePoint.xa; ya = hLinePoint && hLinePoint.ya; var ySnap = ya.spikesnap; @@ -1297,13 +1294,12 @@ function createSpikelines(closestPoints, opts) { } var dfltHLineColor = tinycolor.readability(hLinePoint.color, contrastColor) < 1.5 ? Color.contrast(contrastColor) : hLinePoint.color; - var yMode = ya.spikemode, - yThickness = ya.spikethickness, - yColor = ya.spikecolor || dfltHLineColor, - yBB = ya._boundingBox, - xEdge = ((yBB.left + yBB.right) / 2) < hLinePointX ? yBB.right : yBB.left, - xBase, - xEndSpike; + var yMode = ya.spikemode; + var yThickness = ya.spikethickness; + var yColor = ya.spikecolor || dfltHLineColor; + var yBB = ya._boundingBox; + var xEdge = ((yBB.left + yBB.right) / 2) < hLinePointX ? yBB.right : yBB.left; + var xBase, xEndSpike; if(yMode.indexOf('toaxis') !== -1 || yMode.indexOf('across') !== -1) { if(yMode.indexOf('toaxis') !== -1) { @@ -1318,12 +1314,12 @@ function createSpikelines(closestPoints, opts) { // Foreground horizontal line (to y-axis) container.insert('line', ':first-child') .attr({ - 'x1': xBase, - 'x2': xEndSpike, - 'y1': hLinePointY, - 'y2': hLinePointY, + x1: xBase, + x2: xEndSpike, + y1: hLinePointY, + y2: hLinePointY, 'stroke-width': yThickness, - 'stroke': yColor, + stroke: yColor, 'stroke-dasharray': Drawing.dashStyle(ya.spikedash, yThickness) }) .classed('spikeline', true) @@ -1332,12 +1328,12 @@ function createSpikelines(closestPoints, opts) { // Background horizontal Line (to y-axis) container.insert('line', ':first-child') .attr({ - 'x1': xBase, - 'x2': xEndSpike, - 'y1': hLinePointY, - 'y2': hLinePointY, + x1: xBase, + x2: xEndSpike, + y1: hLinePointY, + y2: hLinePointY, 'stroke-width': yThickness + 2, - 'stroke': contrastColor + stroke: contrastColor }) .classed('spikeline', true) .classed('crisp', true); @@ -1346,19 +1342,18 @@ function createSpikelines(closestPoints, opts) { if(yMode.indexOf('marker') !== -1) { container.insert('circle', ':first-child') .attr({ - 'cx': xEdge + (ya.side !== 'right' ? yThickness : -yThickness), - 'cy': hLinePointY, - 'r': yThickness, - 'fill': yColor + cx: xEdge + (ya.side !== 'right' ? yThickness : -yThickness), + cy: hLinePointY, + r: yThickness, + fill: yColor }) .classed('spikeline', true); } } if(showX) { - var vLinePoint = closestPoints.vLinePoint, - vLinePointX, - vLinePointY; + var vLinePoint = closestPoints.vLinePoint; + var vLinePointX, vLinePointY; xa = vLinePoint && vLinePoint.xa; ya = vLinePoint && vLinePoint.ya; @@ -1372,14 +1367,13 @@ function createSpikelines(closestPoints, opts) { vLinePointY = ya._offset + vLinePoint.y; } var dfltVLineColor = tinycolor.readability(vLinePoint.color, contrastColor) < 1.5 ? - Color.contrast(contrastColor) : vLinePoint.color; - var xMode = xa.spikemode, - xThickness = xa.spikethickness, - xColor = xa.spikecolor || dfltVLineColor, - xBB = xa._boundingBox, - yEdge = ((xBB.top + xBB.bottom) / 2) < vLinePointY ? xBB.bottom : xBB.top, - yBase, - yEndSpike; + Color.contrast(contrastColor) : vLinePoint.color; + var xMode = xa.spikemode; + var xThickness = xa.spikethickness; + var xColor = xa.spikecolor || dfltVLineColor; + var xBB = xa._boundingBox; + var yEdge = ((xBB.top + xBB.bottom) / 2) < vLinePointY ? xBB.bottom : xBB.top; + var yBase, yEndSpike; if(xMode.indexOf('toaxis') !== -1 || xMode.indexOf('across') !== -1) { if(xMode.indexOf('toaxis') !== -1) { @@ -1394,12 +1388,12 @@ function createSpikelines(closestPoints, opts) { // Foreground vertical line (to x-axis) container.insert('line', ':first-child') .attr({ - 'x1': vLinePointX, - 'x2': vLinePointX, - 'y1': yBase, - 'y2': yEndSpike, + x1: vLinePointX, + x2: vLinePointX, + y1: yBase, + y2: yEndSpike, 'stroke-width': xThickness, - 'stroke': xColor, + stroke: xColor, 'stroke-dasharray': Drawing.dashStyle(xa.spikedash, xThickness) }) .classed('spikeline', true) @@ -1408,12 +1402,12 @@ function createSpikelines(closestPoints, opts) { // Background vertical line (to x-axis) container.insert('line', ':first-child') .attr({ - 'x1': vLinePointX, - 'x2': vLinePointX, - 'y1': yBase, - 'y2': yEndSpike, + x1: vLinePointX, + x2: vLinePointX, + y1: yBase, + y2: yEndSpike, 'stroke-width': xThickness + 2, - 'stroke': contrastColor + stroke: contrastColor }) .classed('spikeline', true) .classed('crisp', true); @@ -1423,10 +1417,10 @@ function createSpikelines(closestPoints, opts) { if(xMode.indexOf('marker') !== -1) { container.insert('circle', ':first-child') .attr({ - 'cx': vLinePointX, - 'cy': yEdge - (xa.side !== 'top' ? xThickness : -xThickness), - 'r': xThickness, - 'fill': xColor + cx: vLinePointX, + cy: yEdge - (xa.side !== 'top' ? xThickness : -xThickness), + r: xThickness, + fill: xColor }) .classed('spikeline', true); } @@ -1438,8 +1432,8 @@ function hoverChanged(gd, evt, oldhoverdata) { if(!oldhoverdata || oldhoverdata.length !== gd._hoverdata.length) return true; for(var i = oldhoverdata.length - 1; i >= 0; i--) { - var oldPt = oldhoverdata[i], - newPt = gd._hoverdata[i]; + var oldPt = oldhoverdata[i]; + var newPt = gd._hoverdata[i]; if(oldPt.curveNumber !== newPt.curveNumber || String(oldPt.pointNumber) !== String(newPt.pointNumber)) { return true; diff --git a/src/constants/numerical.js b/src/constants/numerical.js index c13765d95bb..77ea5abf465 100644 --- a/src/constants/numerical.js +++ b/src/constants/numerical.js @@ -49,6 +49,12 @@ module.exports = { */ ALMOST_EQUAL: 1 - 1e-6, + /* + * If we're asked to clip a non-positive log value, how far off-screen + * do we put it? + */ + LOG_CLIP: 10, + /* * not a number, but for displaying numbers: the "minus sign" symbol is * wider than the regular ascii dash "-" diff --git a/src/lib/index.js b/src/lib/index.js index 2c2177ae2b0..26ee71bf1db 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -65,6 +65,7 @@ lib.sorterAsc = searchModule.sorterAsc; lib.sorterDes = searchModule.sorterDes; lib.distinctVals = searchModule.distinctVals; lib.roundUp = searchModule.roundUp; +lib.sort = searchModule.sort; lib.findIndexOfMin = searchModule.findIndexOfMin; var statsModule = require('./stats'); diff --git a/src/lib/search.js b/src/lib/search.js index 056d707e54a..8fcc5ed9bb1 100644 --- a/src/lib/search.js +++ b/src/lib/search.js @@ -115,6 +115,48 @@ exports.roundUp = function(val, arrayIn, reverse) { return arrayIn[low]; }; +/** + * Tweak to Array.sort(sortFn) that improves performance for pre-sorted arrays + * + * Motivation: sometimes we need to sort arrays but the input is likely to + * already be sorted. Browsers don't seem to pick up on pre-sorted arrays, + * and in fact Chrome is actually *slower* sorting pre-sorted arrays than purely + * random arrays. FF is at least faster if the array is pre-sorted, but still + * not as fast as it could be. + * Here's how this plays out sorting a length-1e6 array: + * + * Calls to Sort FN | Chrome bare | FF bare | Chrome tweak | FF tweak + * | v68.0 Mac | v61.0 Mac| | + * ------------------+---------------+-----------+----------------+------------ + * ordered | 30.4e6 | 10.1e6 | 1e6 | 1e6 + * reversed | 29.4e6 | 9.9e6 | 1e6 + reverse | 1e6 + reverse + * random | ~21e6 | ~18.7e6 | ~21e6 | ~18.7e6 + * + * So this is a substantial win for pre-sorted (ordered or exactly reversed) + * arrays. Including this wrapper on an unsorted array adds a penalty that will + * in general be only a few calls to the sort function. The only case this + * penalty will be significant is if the array is mostly sorted but there are + * a few unsorted items near the end, but the penalty is still at most N calls + * out of (for N=1e6) ~20N total calls + * + * @param {Array} array: the array, to be sorted in place + * @param {function} sortFn: As in Array.sort, function(a, b) that puts + * item a before item b if the return is negative, a after b if positive, + * and no change if zero. + * @return {Array}: the original array, sorted in place. + */ +exports.sort = function(array, sortFn) { + var notOrdered = 0; + var notReversed = 0; + for(var i = 1; i < array.length; i++) { + var pairOrder = sortFn(array[i], array[i - 1]); + if(pairOrder < 0) notOrdered = 1; + else if(pairOrder > 0) notReversed = 1; + if(notOrdered && notReversed) return array.sort(sortFn); + } + return notReversed ? array : array.reverse(); +}; + /** * find index in array 'arr' that minimizes 'fn' * diff --git a/src/plots/cartesian/autorange.js b/src/plots/cartesian/autorange.js index eaab8f37b5d..3343fb4a899 100644 --- a/src/plots/cartesian/autorange.js +++ b/src/plots/cartesian/autorange.js @@ -87,6 +87,13 @@ function getAutoRange(gd, ax) { ax.autorange = true; } + var rangeMode = ax.rangemode; + var toZero = rangeMode === 'tozero'; + var nonNegative = rangeMode === 'nonnegative'; + var axLen = ax._length; + // don't allow padding to reduce the data to < 10% of the length + var minSpan = axLen / 10; + var mbest = 0; var minpt, maxpt, minbest, maxbest, dp, dv; @@ -95,76 +102,83 @@ function getAutoRange(gd, ax) { for(j = 0; j < maxArray.length; j++) { maxpt = maxArray[j]; dv = maxpt.val - minpt.val; - dp = ax._length - getPad(minpt) - getPad(maxpt); - if(dv > 0 && dp > 0 && dv / dp > mbest) { - minbest = minpt; - maxbest = maxpt; - mbest = dv / dp; + if(dv > 0) { + dp = axLen - getPad(minpt) - getPad(maxpt); + if(dp > minSpan) { + if(dv / dp > mbest) { + minbest = minpt; + maxbest = maxpt; + mbest = dv / dp; + } + } + else if(dv / axLen > mbest) { + // in case of padding longer than the axis + // at least include the unpadded data values. + minbest = {val: minpt.val, pad: 0}; + maxbest = {val: maxpt.val, pad: 0}; + mbest = dv / axLen; + } } } } + function getMaxPad(prev, pt) { + return Math.max(prev, getPad(pt)); + } + if(minmin === maxmax) { var lower = minmin - 1; var upper = minmin + 1; - if(ax.rangemode === 'tozero') { - newRange = minmin < 0 ? [lower, 0] : [0, upper]; - } else if(ax.rangemode === 'nonnegative') { - newRange = [Math.max(0, lower), Math.max(0, upper)]; + if(toZero) { + if(minmin === 0) { + // The only value we have on this axis is 0, and we want to + // autorange so zero is one end. + // In principle this could be [0, 1] or [-1, 0] but usually + // 'tozero' pins 0 to the low end, so follow that. + newRange = [0, 1]; + } + else { + var maxPad = (minmin > 0 ? maxArray : minArray).reduce(getMaxPad, 0); + // we're pushing a single value away from the edge due to its + // padding, with the other end clamped at zero + // 0.5 means don't push it farther than the center. + var rangeEnd = minmin / (1 - Math.min(0.5, maxPad / axLen)); + newRange = minmin > 0 ? [0, rangeEnd] : [rangeEnd, 0]; + } + } else if(nonNegative) { + newRange = [Math.max(0, lower), Math.max(1, upper)]; } else { newRange = [lower, upper]; } } - else if(mbest) { - if(ax.type === 'linear' || ax.type === '-') { - if(ax.rangemode === 'tozero') { - if(minbest.val >= 0) { - minbest = {val: 0, pad: 0}; - } - if(maxbest.val <= 0) { - maxbest = {val: 0, pad: 0}; - } + else { + if(toZero) { + if(minbest.val >= 0) { + minbest = {val: 0, pad: 0}; } - else if(ax.rangemode === 'nonnegative') { - if(minbest.val - mbest * getPad(minbest) < 0) { - minbest = {val: 0, pad: 0}; - } - if(maxbest.val < 0) { - maxbest = {val: 1, pad: 0}; - } + if(maxbest.val <= 0) { + maxbest = {val: 0, pad: 0}; + } + } + else if(nonNegative) { + if(minbest.val - mbest * getPad(minbest) < 0) { + minbest = {val: 0, pad: 0}; + } + if(maxbest.val <= 0) { + maxbest = {val: 1, pad: 0}; } - - // in case it changed again... - mbest = (maxbest.val - minbest.val) / - (ax._length - getPad(minbest) - getPad(maxbest)); - } + // in case it changed again... + mbest = (maxbest.val - minbest.val) / + (axLen - getPad(minbest) - getPad(maxbest)); + newRange = [ minbest.val - mbest * getPad(minbest), maxbest.val + mbest * getPad(maxbest) ]; } - // don't let axis have zero size, while still respecting tozero and nonnegative - if(newRange[0] === newRange[1]) { - if(ax.rangemode === 'tozero') { - if(newRange[0] < 0) { - newRange = [newRange[0], 0]; - } else if(newRange[0] > 0) { - newRange = [0, newRange[0]]; - } else { - newRange = [0, 1]; - } - } - else { - newRange = [newRange[0] - 1, newRange[0] + 1]; - if(ax.rangemode === 'nonnegative') { - newRange[0] = Math.max(0, newRange[0]); - } - } - } - // maintain reversal if(axReverse) newRange.reverse(); diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 69141a0b784..421fb0e0633 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -20,6 +20,8 @@ var Titles = require('../../components/titles'); var Color = require('../../components/color'); var Drawing = require('../../components/drawing'); +var axAttrs = require('./layout_attributes'); + var constants = require('../../constants/numerical'); var ONEAVGYEAR = constants.ONEAVGYEAR; var ONEAVGMONTH = constants.ONEAVGMONTH; @@ -2411,11 +2413,12 @@ function swapAxisGroup(gd, xIds, yIds) { for(i = 0; i < xIds.length; i++) xFullAxes.push(axes.getFromId(gd, xIds[i])); for(i = 0; i < yIds.length; i++) yFullAxes.push(axes.getFromId(gd, yIds[i])); - var allAxKeys = Object.keys(xFullAxes[0]), - noSwapAttrs = [ - 'anchor', 'domain', 'overlaying', 'position', 'side', 'tickangle' - ], - numericTypes = ['linear', 'log']; + var allAxKeys = Object.keys(axAttrs); + + var noSwapAttrs = [ + 'anchor', 'domain', 'overlaying', 'position', 'side', 'tickangle', 'editType' + ]; + var numericTypes = ['linear', 'log']; for(i = 0; i < allAxKeys.length; i++) { var keyi = allAxKeys[i], diff --git a/src/plots/cartesian/axis_defaults.js b/src/plots/cartesian/axis_defaults.js index 06de3b99378..f3691bf8003 100644 --- a/src/plots/cartesian/axis_defaults.js +++ b/src/plots/cartesian/axis_defaults.js @@ -48,7 +48,7 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, coerce, setConvert(containerOut, layoutOut); var autoRange = coerce('autorange', !containerOut.isValidRange(containerIn.range)); - if(autoRange) coerce('rangemode'); + if(autoRange && (axType === 'linear' || axType === '-')) coerce('rangemode'); coerce('range'); containerOut.cleanRange(); diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 580f040990a..3d81797b7e9 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -99,7 +99,8 @@ module.exports = { 'If *tozero*`, the range extends to 0,', 'regardless of the input data', 'If *nonnegative*, the range is non-negative,', - 'regardless of the input data.' + 'regardless of the input data.', + 'Applies only to linear axes.' ].join(' ') }, range: { diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js index b239ffb2e58..e057aba7b20 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -21,6 +21,7 @@ var ensureNumber = Lib.ensureNumber; var numConstants = require('../../constants/numerical'); var FP_SAFE = numConstants.FP_SAFE; var BADNUM = numConstants.BADNUM; +var LOG_CLIP = numConstants.LOG_CLIP; var constants = require('./constants'); var axisIds = require('./axis_ids'); @@ -59,20 +60,15 @@ module.exports = function setConvert(ax, fullLayout) { var axLetter = (ax._id || 'x').charAt(0); - // clipMult: how many axis lengths past the edge do we render? - // for panning, 1-2 would suffice, but for zooming more is nice. - // also, clipping can affect the direction of lines off the edge... - var clipMult = 10; - function toLog(v, clip) { if(v > 0) return Math.log(v) / Math.LN10; else if(v <= 0 && clip && ax.range && ax.range.length === 2) { - // clip NaN (ie past negative infinity) to clipMult axis + // clip NaN (ie past negative infinity) to LOG_CLIP axis // length past the negative edge var r0 = ax.range[0], r1 = ax.range[1]; - return 0.5 * (r0 + r1 - 3 * clipMult * Math.abs(r0 - r1)); + return 0.5 * (r0 + r1 - 2 * LOG_CLIP * Math.abs(r0 - r1)); } else return BADNUM; diff --git a/src/plots/plots.js b/src/plots/plots.js index c0a2bb36e84..147f83a3914 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -391,6 +391,11 @@ plots.supplyDefaults = function(gd, opts) { // initialize splom grid defaults newFullLayout._splomGridDflt = {}; + // for stacked area traces to share config across traces + newFullLayout._scatterStackOpts = {}; + // for the first scatter trace on each subplot (so it knows tonext->tozero) + newFullLayout._firstScatter = {}; + // for traces to request a default rangeslider on their x axes // eg set `_requestRangeslider.x2 = true` for xaxis2 newFullLayout._requestRangeslider = {}; @@ -938,8 +943,6 @@ plots.supplyDataDefaults = function(dataIn, dataOut, layout, fullLayout) { fullTrace.uid = fullLayout._traceUids[i]; plots.supplyTraceDefaults(trace, fullTrace, colorCnt, fullLayout, i); - fullTrace.uid = fullLayout._traceUids[i]; - fullTrace.index = i; fullTrace._input = trace; fullTrace._expandedIndex = cnt; @@ -1559,7 +1562,6 @@ plots.purge = function(gd) { // (and to have a record of them...) delete gd._promises; delete gd._redrawTimer; - delete gd.firstscatter; delete gd._hmlumcount; delete gd._hmpixcount; delete gd._transitionData; @@ -2424,8 +2426,6 @@ plots.doCalcdata = function(gd, traces) { gd.calcdata = calcdata; // extra helper variables - // firstscatter: fill-to-next on the first trace goes to zero - gd.firstscatter = true; // how many box/violins plots do we have (in case they're grouped) fullLayout._numBoxes = 0; diff --git a/src/plots/polar/layout_defaults.js b/src/plots/polar/layout_defaults.js index 42fcbad3a4d..f8bb7a9103b 100644 --- a/src/plots/polar/layout_defaults.js +++ b/src/plots/polar/layout_defaults.js @@ -91,7 +91,7 @@ function handleDefaults(contIn, contOut, coerce, opts) { case 'radialaxis': var autoRange = coerceAxis('autorange', !axOut.isValidRange(axIn.range)); axIn.autorange = autoRange; - if(autoRange) coerceAxis('rangemode'); + if(autoRange && (axType === 'linear' || axType === '-')) coerceAxis('rangemode'); if(autoRange === 'reversed') axOut._m = -1; coerceAxis('range'); diff --git a/src/traces/bar/layout_attributes.js b/src/traces/bar/layout_attributes.js index aa84ce21d55..a78a2107ca7 100644 --- a/src/traces/bar/layout_attributes.js +++ b/src/traces/bar/layout_attributes.js @@ -36,9 +36,9 @@ module.exports = { editType: 'calc', description: [ 'Sets the normalization for bar traces on the graph.', - 'With *fraction*, the value of each bar is divide by the sum of the', - 'values at the location coordinate.', - 'With *percent*, the results form *fraction* are presented in percents.' + 'With *fraction*, the value of each bar is divided by the sum of all', + 'values at that location coordinate.', + '*percent* is the same but multiplied by 100 to show percentages.' ].join(' ') }, bargap: { diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index d8a17c934e8..6c5c2edf7f3 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -72,6 +72,74 @@ module.exports = { 'See `y0` for more info.' ].join(' ') }, + + stackgroup: { + valType: 'string', + role: 'info', + dflt: '', + editType: 'calc', + description: [ + 'Set several scatter traces (on the same subplot) to the same', + 'stackgroup in order to add their y values (or their x values if', + '`orientation` is *h*). If blank or omitted this trace will not be', + 'stacked. Stacking also turns `fill` on by default, using *tonexty*', + '(*tonextx*) if `orientation` is *h* (*v*) and sets the default', + '`mode` to *lines* irrespective of point count.', + 'You can only stack on a numeric (linear or log) axis.' + ].join(' ') + }, + orientation: { + valType: 'enumerated', + role: 'info', + values: ['v', 'h'], + editType: 'calc', + description: [ + 'Only relevant when `stackgroup` is used, and only the first', + '`orientation` found in the `stackgroup` will be used - including', + 'if `visible` is *legendonly* but not if it is `false`. Sets the', + 'stacking direction. With *v* (*h*), the y (x) values of subsequent', + 'traces are added. Also affects the default value of `fill`.' + ].join(' ') + }, + groupnorm: { + valType: 'enumerated', + values: ['', 'fraction', 'percent'], + dflt: '', + role: 'info', + editType: 'calc', + description: [ + 'Only relevant when `stackgroup` is used, and only the first', + '`groupnorm` found in the `stackgroup` will be used - including', + 'if `visible` is *legendonly* but not if it is `false`.', + 'Sets the normalization for the sum of this `stackgroup`.', + 'With *fraction*, the value of each trace at each location is', + 'divided by the sum of all trace values at that location.', + '*percent* is the same but multiplied by 100 to show percentages.', + 'If there are multiple subplots, or multiple `stackgroup`s on one', + 'subplot, each will be normalized within its own set.' + ].join(' ') + }, + stackgaps: { + valType: 'enumerated', + values: ['infer zero', 'interpolate'], + dflt: 'infer zero', + role: 'info', + editType: 'calc', + description: [ + 'Only relevant when `stackgroup` is used, and only the first', + '`stackgaps` found in the `stackgroup` will be used - including', + 'if `visible` is *legendonly* but not if it is `false`.', + 'Determines how we handle locations at which other traces in this', + 'group have data but this one does not.', + 'With *infer zero* we insert a zero at these locations.', + 'With *interpolate* we linearly interpolate between existing', + 'values, and extrapolate a constant beyond the existing values.' + // TODO - implement interrupt mode + // '*interrupt* omits this trace from the stack at this location by', + // 'dropping abruptly, midway between the existing and missing locations.' + ].join(' ') + }, + text: { valType: 'string', role: 'info', @@ -114,7 +182,8 @@ module.exports = { 'If the provided `mode` includes *text* then the `text` elements', 'appear at the coordinates. Otherwise, the `text` elements', 'appear on hover.', - 'If there are less than ' + constants.PTS_LINESONLY + ' points,', + 'If there are less than ' + constants.PTS_LINESONLY + ' points', + 'and the trace is not stacked', 'then the default is *lines+markers*. Otherwise, *lines*.' ].join(' ') }, @@ -212,11 +281,12 @@ module.exports = { fill: { valType: 'enumerated', values: ['none', 'tozeroy', 'tozerox', 'tonexty', 'tonextx', 'toself', 'tonext'], - dflt: 'none', role: 'style', editType: 'calc', description: [ 'Sets the area to fill with a solid color.', + 'Defaults to *none* unless this trace is stacked, then it gets', + '*tonexty* (*tonextx*) if `orientation` is *v* (*h*)', 'Use with `fillcolor` if not *none*.', '*tozerox* and *tozeroy* fill to x=0 and y=0 respectively.', '*tonextx* and *tonexty* fill between the endpoints of this', diff --git a/src/traces/scatter/calc.js b/src/traces/scatter/calc.js index 37a9068c8e9..2ff8728073c 100644 --- a/src/traces/scatter/calc.js +++ b/src/traces/scatter/calc.js @@ -9,7 +9,7 @@ 'use strict'; var isNumeric = require('fast-isnumeric'); -var isArrayOrTypedArray = require('../../lib').isArrayOrTypedArray; +var Lib = require('../../lib'); var Axes = require('../../plots/cartesian/axes'); var BADNUM = require('../../constants/numerical').BADNUM; @@ -20,23 +20,69 @@ var arraysToCalcdata = require('./arrays_to_calcdata'); var calcSelection = require('./calc_selection'); function calc(gd, trace) { + var fullLayout = gd._fullLayout; var xa = Axes.getFromId(gd, trace.xaxis || 'x'); var ya = Axes.getFromId(gd, trace.yaxis || 'y'); var x = xa.makeCalcdata(trace, 'x'); var y = ya.makeCalcdata(trace, 'y'); var serieslen = trace._length; var cd = new Array(serieslen); + var ids = trace.ids; + var stackGroupOpts = getStackOpts(trace, fullLayout, xa, ya); + var interpolateGaps = false; + var isV, i, j, k, interpolate, vali; - var ppad = calcMarkerSize(trace, serieslen); - calcAxisExpansion(gd, trace, xa, ya, x, y, ppad); + setFirstScatter(fullLayout, trace); - for(var i = 0; i < serieslen; i++) { - cd[i] = (isNumeric(x[i]) && isNumeric(y[i])) ? - {x: x[i], y: y[i]} : - {x: BADNUM, y: BADNUM}; + var xAttr = 'x'; + var yAttr = 'y'; + var posAttr; + if(stackGroupOpts) { + stackGroupOpts.traceIndices.push(trace.index); + isV = stackGroupOpts.orientation === 'v'; + // size, like we use for bar + if(isV) { + yAttr = 's'; + posAttr = 'x'; + } + else { + xAttr = 's'; + posAttr = 'y'; + } + interpolate = stackGroupOpts.stackgaps === 'interpolate'; + } + else { + var ppad = calcMarkerSize(trace, serieslen); + calcAxisExpansion(gd, trace, xa, ya, x, y, ppad); + } + + for(i = 0; i < serieslen; i++) { + var cdi = cd[i] = {}; + var xValid = isNumeric(x[i]); + var yValid = isNumeric(y[i]); + if(xValid && yValid) { + cdi[xAttr] = x[i]; + cdi[yAttr] = y[i]; + } + // if we're stacking we need to hold on to all valid positions + // even with invalid sizes + else if(stackGroupOpts && (isV ? xValid : yValid)) { + cdi[posAttr] = isV ? x[i] : y[i]; + cdi.gap = true; + if(interpolate) { + cdi.s = BADNUM; + interpolateGaps = true; + } + else { + cdi.s = 0; + } + } + else { + cdi[xAttr] = cdi[yAttr] = BADNUM; + } - if(trace.ids) { - cd[i].id = String(trace.ids[i]); + if(ids) { + cdi.id = String(ids[i]); } } @@ -44,12 +90,72 @@ function calc(gd, trace) { calcColorscale(trace); calcSelection(cd, trace); - gd.firstscatter = false; + if(stackGroupOpts) { + // remove bad positions and sort + // note that original indices get added to cd in arraysToCalcdata + i = 0; + while(i < cd.length) { + if(cd[i][posAttr] === BADNUM) { + cd.splice(i, 1); + } + else i++; + } + + Lib.sort(cd, function(a, b) { + return (a[posAttr] - b[posAttr]) || (a.i - b.i); + }); + + if(interpolateGaps) { + // first fill the beginning with constant from the first point + i = 0; + while(i < cd.length - 1 && cd[i].gap) { + i++; + } + vali = cd[i].s; + if(!vali) vali = cd[i].s = 0; // in case of no data AT ALL in this trace - use 0 + for(j = 0; j < i; j++) { + cd[j].s = vali; + } + // then fill the end with constant from the last point + k = cd.length - 1; + while(k > i && cd[k].gap) { + k--; + } + vali = cd[k].s; + for(j = cd.length - 1; j > k; j--) { + cd[j].s = vali; + } + // now interpolate internal gaps linearly + while(i < k) { + i++; + if(cd[i].gap) { + j = i + 1; + while(cd[j].gap) { + j++; + } + var pos0 = cd[i - 1][posAttr]; + var size0 = cd[i - 1].s; + var m = (cd[j].s - size0) / (cd[j][posAttr] - pos0); + while(i < j) { + cd[i].s = size0 + (cd[i][posAttr] - pos0) * m; + i++; + } + } + } + } + } + return cd; } function calcAxisExpansion(gd, trace, xa, ya, x, y, ppad) { var serieslen = trace._length; + var fullLayout = gd._fullLayout; + var xId = xa._id; + var yId = ya._id; + var firstScatter = fullLayout._firstScatter[xId + yId + trace.type] === trace.uid; + var stackOrientation = (getStackOpts(trace, fullLayout, xa, ya) || {}).orientation; + var fill = trace.fill; // cancel minimum tick spacings (only applies to bars and boxes) xa._minDtick = 0; @@ -66,17 +172,20 @@ function calcAxisExpansion(gd, trace, xa, ya, x, y, ppad) { // TODO: text size + var openEnded = serieslen < 2 || (x[0] !== x[serieslen - 1]) || (y[0] !== y[serieslen - 1]); + // include zero (tight) and extremes (padded) if fill to zero // (unless the shape is closed, then it's just filling the shape regardless) - if(((trace.fill === 'tozerox') || - ((trace.fill === 'tonextx') && gd.firstscatter)) && - ((x[0] !== x[serieslen - 1]) || (y[0] !== y[serieslen - 1]))) { + if(openEnded && ( + (fill === 'tozerox') || + ((fill === 'tonextx') && (firstScatter || stackOrientation === 'h')) + )) { xOptions.tozero = true; } // if no error bars, markers or text, or fill to y=0 remove x padding else if(!(trace.error_y || {}).visible && ( - ['tonexty', 'tozeroy'].indexOf(trace.fill) !== -1 || + (fill === 'tonexty' || fill === 'tozeroy') || (!subTypes.hasMarkers(trace) && !subTypes.hasText(trace)) )) { xOptions.padded = false; @@ -86,19 +195,21 @@ function calcAxisExpansion(gd, trace, xa, ya, x, y, ppad) { // now check for y - rather different logic, though still mostly padded both ends // include zero (tight) and extremes (padded) if fill to zero // (unless the shape is closed, then it's just filling the shape regardless) - if(((trace.fill === 'tozeroy') || ((trace.fill === 'tonexty') && gd.firstscatter)) && - ((x[0] !== x[serieslen - 1]) || (y[0] !== y[serieslen - 1]))) { + if(openEnded && ( + (fill === 'tozeroy') || + ((fill === 'tonexty') && (firstScatter || stackOrientation === 'v')) + )) { yOptions.tozero = true; } // tight y: any x fill - else if(['tonextx', 'tozerox'].indexOf(trace.fill) !== -1) { + else if(fill === 'tonextx' || fill === 'tozerox') { yOptions.padded = false; } // N.B. asymmetric splom traces call this with blank {} xa or ya - if(xa._id) trace._extremes[xa._id] = Axes.findExtremes(xa, x, xOptions); - if(ya._id) trace._extremes[ya._id] = Axes.findExtremes(ya, y, yOptions); + if(xId) trace._extremes[xId] = Axes.findExtremes(xa, x, xOptions); + if(yId) trace._extremes[yId] = Axes.findExtremes(ya, y, yOptions); } function calcMarkerSize(trace, serieslen) { @@ -120,7 +231,7 @@ function calcMarkerSize(trace, serieslen) { }; } - if(isArrayOrTypedArray(marker.size)) { + if(Lib.isArrayOrTypedArray(marker.size)) { // I tried auto-type but category and dates dont make much sense. var ax = {type: 'linear'}; Axes.setConvert(ax); @@ -138,8 +249,34 @@ function calcMarkerSize(trace, serieslen) { } } +/** + * mark the first scatter trace for each subplot + * note that scatter and scattergl each get their own first trace + * note also that I'm doing this during calc rather than supplyDefaults + * so I don't need to worry about transforms, but if we ever do + * per-trace calc this will get confused. + */ +function setFirstScatter(fullLayout, trace) { + var subplotAndType = trace.xaxis + trace.yaxis + trace.type; + var firstScatter = fullLayout._firstScatter; + if(!firstScatter[subplotAndType]) firstScatter[subplotAndType] = trace.uid; +} + +function getStackOpts(trace, fullLayout, xa, ya) { + var stackGroup = trace.stackgroup; + if(!stackGroup) return; + var stackOpts = fullLayout._scatterStackOpts[xa._id + ya._id][stackGroup]; + var stackAx = stackOpts.orientation === 'v' ? ya : xa; + // Allow stacking only on numeric axes + // calc is a little late to be figuring this out, but during supplyDefaults + // we don't know the axis type yet + if(stackAx.type === 'linear' || stackAx.type === 'log') return stackOpts; +} + module.exports = { calc: calc, calcMarkerSize: calcMarkerSize, - calcAxisExpansion: calcAxisExpansion + calcAxisExpansion: calcAxisExpansion, + setFirstScatter: setFirstScatter, + getStackOpts: getStackOpts }; diff --git a/src/traces/scatter/cross_trace_calc.js b/src/traces/scatter/cross_trace_calc.js new file mode 100644 index 00000000000..64db8eaad7d --- /dev/null +++ b/src/traces/scatter/cross_trace_calc.js @@ -0,0 +1,181 @@ +/** +* 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 calc = require('./calc'); + +/* + * Scatter stacking & normalization calculations + * runs per subplot, and can handle multiple stacking groups + */ + +module.exports = function crossTraceCalc(gd, plotinfo) { + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; + var subplot = xa._id + ya._id; + + var subplotStackOpts = gd._fullLayout._scatterStackOpts[subplot]; + if(!subplotStackOpts) return; + + var calcTraces = gd.calcdata; + + var i, j, k, i2, cd, cd0, posj, sumj, norm; + var groupOpts, interpolate, groupnorm, posAttr, valAttr; + var hasAnyBlanks; + + function insertBlank(calcTrace, index, position, traceIndex) { + hasAnyBlanks[traceIndex] = true; + var newEntry = { + i: null, + gap: true, + s: 0 + }; + newEntry[posAttr] = position; + calcTrace.splice(index, 0, newEntry); + // Even if we're not interpolating, if one trace has multiple + // values at the same position and this trace only has one value there, + // we just duplicate that one value rather than insert a zero. + // We also make it look like a real point - because it's ambiguous which + // one really is the real one! + if(index && position === calcTrace[index - 1][posAttr]) { + var prevEntry = calcTrace[index - 1]; + newEntry.s = prevEntry.s; + // TODO is it going to cause any problems to have multiple + // calcdata points with the same index? + newEntry.i = prevEntry.i; + newEntry.gap = prevEntry.gap; + } + else if(interpolate) { + newEntry.s = getInterp(calcTrace, index, position); + } + if(!index) { + // t and trace need to stay on the first cd entry + cd[0].t = cd[1].t; + cd[0].trace = cd[1].trace; + delete cd[1].t; + delete cd[1].trace; + } + } + + function getInterp(calcTrace, index, position) { + var pt0 = calcTrace[index - 1]; + var pt1 = calcTrace[index + 1]; + if(!pt1) return pt0.s; + if(!pt0) return pt1.s; + return pt0.s + (pt1.s - pt0.s) * (position - pt0[posAttr]) / (pt1[posAttr] - pt0[posAttr]); + } + + for(var stackGroup in subplotStackOpts) { + groupOpts = subplotStackOpts[stackGroup]; + var indices = groupOpts.traceIndices; + + // can get here with no indices if the stack axis is non-numeric + if(!indices.length) continue; + + interpolate = groupOpts.stackgaps === 'interpolate'; + groupnorm = groupOpts.groupnorm; + if(groupOpts.orientation === 'v') { + posAttr = 'x'; + valAttr = 'y'; + } + else { + posAttr = 'y'; + valAttr = 'x'; + } + hasAnyBlanks = new Array(indices.length); + for(i = 0; i < hasAnyBlanks.length; i++) { + hasAnyBlanks[i] = false; + } + + // Collect the complete set of all positions across ALL traces. + // Start with the first trace, then interleave items from later traces + // as needed. + // Fill in mising items as we go. + cd0 = calcTraces[indices[0]]; + var allPositions = new Array(cd0.length); + for(i = 0; i < cd0.length; i++) { + allPositions[i] = cd0[i][posAttr]; + } + + for(i = 1; i < indices.length; i++) { + cd = calcTraces[indices[i]]; + + for(j = k = 0; j < cd.length; j++) { + posj = cd[j][posAttr]; + for(; posj > allPositions[k] && k < allPositions.length; k++) { + // the current trace is missing a position from some previous trace(s) + insertBlank(cd, j, allPositions[k], i); + j++; + } + if(posj !== allPositions[k]) { + // previous trace(s) are missing a position from the current trace + for(i2 = 0; i2 < i; i2++) { + insertBlank(calcTraces[indices[i2]], k, posj, i2); + } + allPositions.splice(k, 0, posj); + } + k++; + } + for(; k < allPositions.length; k++) { + insertBlank(cd, j, allPositions[k], i); + j++; + } + } + + var serieslen = allPositions.length; + + // stack (and normalize)! + for(j = 0; j < cd0.length; j++) { + sumj = cd0[j][valAttr] = cd0[j].s; + for(i = 1; i < indices.length; i++) { + cd = calcTraces[indices[i]]; + cd[0].trace._rawLength = cd[0].trace._length; + cd[0].trace._length = serieslen; + sumj += cd[j].s; + cd[j][valAttr] = sumj; + } + + if(groupnorm) { + norm = ((groupnorm === 'fraction') ? sumj : (sumj / 100)) || 1; + for(i = 0; i < indices.length; i++) { + var cdj = calcTraces[indices[i]][j]; + cdj[valAttr] /= norm; + cdj.sNorm = cdj.s / norm; + } + } + } + + // autorange + for(i = 0; i < indices.length; i++) { + cd = calcTraces[indices[i]]; + var trace = cd[0].trace; + var ppad = calc.calcMarkerSize(trace, trace._rawLength); + var arrayPad = Array.isArray(ppad); + if((ppad && hasAnyBlanks[i]) || arrayPad) { + var ppadRaw = ppad; + ppad = new Array(serieslen); + for(j = 0; j < serieslen; j++) { + ppad[j] = cd[j].gap ? 0 : (arrayPad ? ppadRaw[cd[j].i] : ppadRaw); + } + } + var x = new Array(serieslen); + var y = new Array(serieslen); + for(j = 0; j < serieslen; j++) { + x[j] = cd[j].x; + y[j] = cd[j].y; + } + calc.calcAxisExpansion(gd, trace, xa, ya, x, y, ppad); + + // while we're here (in a loop over all traces in the stack) + // record the orientation, so hover can find it easily + cd[0].t.orientation = groupOpts.orientation; + } + } +}; diff --git a/src/traces/scatter/defaults.js b/src/traces/scatter/defaults.js index ba9fa7c5c2c..5668c9ffd9d 100644 --- a/src/traces/scatter/defaults.js +++ b/src/traces/scatter/defaults.js @@ -15,6 +15,7 @@ var attributes = require('./attributes'); var constants = require('./constants'); var subTypes = require('./subtypes'); var handleXYDefaults = require('./xy_defaults'); +var handleStackDefaults = require('./stack_defaults'); var handleMarkerDefaults = require('./marker_defaults'); var handleLineDefaults = require('./line_defaults'); var handleLineShapeDefaults = require('./line_shape_defaults'); @@ -26,14 +27,15 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); } - var len = handleXYDefaults(traceIn, traceOut, layout, coerce), - // TODO: default mode by orphan points... - defaultMode = len < constants.PTS_LINESONLY ? 'lines+markers' : 'lines'; - if(!len) { - traceOut.visible = false; - return; - } + var len = handleXYDefaults(traceIn, traceOut, layout, coerce); + if(!len) traceOut.visible = false; + + if(!traceOut.visible) return; + + var stackGroupOpts = handleStackDefaults(traceIn, traceOut, layout, coerce); + var defaultMode = !stackGroupOpts && (len < constants.PTS_LINESONLY) ? + 'lines+markers' : 'lines'; coerce('text'); coerce('hovertext'); coerce('mode', defaultMode); @@ -61,7 +63,9 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout dfltHoverOn.push('points'); } - coerce('fill'); + // It's possible for this default to be changed by a later trace. + // We handle that case in some hacky code inside handleStackDefaults. + coerce('fill', stackGroupOpts ? stackGroupOpts.fillDflt : 'none'); if(traceOut.fill !== 'none') { handleFillColorDefaults(traceIn, traceOut, defaultColor, coerce); if(!subTypes.hasLines(traceOut)) handleLineShapeDefaults(traceIn, traceOut, coerce); diff --git a/src/traces/scatter/hover.js b/src/traces/scatter/hover.js index f85a1a77094..6efe4ca9053 100644 --- a/src/traces/scatter/hover.js +++ b/src/traces/scatter/hover.js @@ -68,16 +68,30 @@ module.exports = function hoverPoints(pointData, xval, yval, hovermode) { var yc = ya.c2p(di.y, true); var rad = di.mrc || 1; + // now we're done using the whole `calcdata` array, replace the + // index with the original index (in case of inserted point from + // stacked area) + pointData.index = di.i; + + var orientation = cd[0].t.orientation; + // TODO: for scatter and bar, option to show (sub)totals and + // raw data? Currently stacked and/or normalized bars just show + // the normalized individual sizes, so that's what I'm doing here + // for now. + var sizeVal = orientation && (di.sNorm || di.s); + var xLabelVal = (orientation === 'h') ? sizeVal : di.x; + var yLabelVal = (orientation === 'v') ? sizeVal : di.y; + Lib.extendFlat(pointData, { color: getTraceColor(trace, di), x0: xc - rad, x1: xc + rad, - xLabelVal: di.x, + xLabelVal: xLabelVal, y0: yc - rad, y1: yc + rad, - yLabelVal: di.y, + yLabelVal: yLabelVal, spikeDistance: dxy(di) }); diff --git a/src/traces/scatter/index.js b/src/traces/scatter/index.js index 020bc06a511..133b54ae32e 100644 --- a/src/traces/scatter/index.js +++ b/src/traces/scatter/index.js @@ -21,6 +21,7 @@ Scatter.attributes = require('./attributes'); Scatter.supplyDefaults = require('./defaults'); Scatter.cleanData = require('./clean_data'); Scatter.calc = require('./calc').calc; +Scatter.crossTraceCalc = require('./cross_trace_calc'); Scatter.arraysToCalcdata = require('./arrays_to_calcdata'); Scatter.plot = require('./plot'); Scatter.colorbar = require('./marker_colorbar'); @@ -33,7 +34,10 @@ Scatter.animatable = true; Scatter.moduleType = 'trace'; Scatter.name = 'scatter'; Scatter.basePlotModule = require('../../plots/cartesian'); -Scatter.categories = ['cartesian', 'svg', 'symbols', 'errorBarsOK', 'showLegend', 'scatter-like', 'zoomScale']; +Scatter.categories = [ + 'cartesian', 'svg', 'symbols', 'errorBarsOK', 'showLegend', 'scatter-like', + 'zoomScale' +]; Scatter.meta = { description: [ 'The scatter trace type encompasses line charts, scatter charts, text charts, and bubble charts.', diff --git a/src/traces/scatter/line_points.js b/src/traces/scatter/line_points.js index 8e498e46466..bf18df449a8 100644 --- a/src/traces/scatter/line_points.js +++ b/src/traces/scatter/line_points.js @@ -9,7 +9,11 @@ 'use strict'; -var BADNUM = require('../../constants/numerical').BADNUM; +var numConstants = require('../../constants/numerical'); +var BADNUM = numConstants.BADNUM; +var LOG_CLIP = numConstants.LOG_CLIP; +var LOG_CLIP_PLUS = LOG_CLIP + 0.5; +var LOG_CLIP_MINUS = LOG_CLIP - 0.5; var Lib = require('../../lib'); var segmentsIntersect = Lib.segmentsIntersect; var constrain = Lib.constrain; @@ -19,6 +23,10 @@ var constants = require('./constants'); module.exports = function linePoints(d, opts) { var xa = opts.xaxis; var ya = opts.yaxis; + var xLog = xa.type === 'log'; + var yLog = ya.type === 'log'; + var xLen = xa._length; + var yLen = ya._length; var connectGaps = opts.connectGaps; var baseTolerance = opts.baseTolerance; var shape = opts.shape; @@ -59,7 +67,25 @@ module.exports = function linePoints(d, opts) { if(!di) return false; var x = xa.c2p(di.x); var y = ya.c2p(di.y); - if(x === BADNUM || y === BADNUM) return false; + + // if non-positive log values, set them VERY far off-screen + // so the line looks essentially straight from the previous point. + if(x === BADNUM) { + if(xLog) x = xa.c2p(di.x, true); + if(x === BADNUM) return false; + // If BOTH were bad log values, make the line follow a constant + // exponent rather than a constant slope + if(yLog && y === BADNUM) { + x *= Math.abs(xa._m * yLen * (xa._m > 0 ? LOG_CLIP_PLUS : LOG_CLIP_MINUS) / + (ya._m * xLen * (ya._m > 0 ? LOG_CLIP_PLUS : LOG_CLIP_MINUS))); + } + x *= 1000; + } + if(y === BADNUM) { + if(yLog) y = ya.c2p(di.y, true); + if(y === BADNUM) return false; + y *= 1000; + } return [x, y]; } @@ -79,8 +105,8 @@ module.exports = function linePoints(d, opts) { var latestXFrac, latestYFrac; // if we're off-screen, increase tolerance over baseTolerance function getTolerance(pt, nextPt) { - var xFrac = pt[0] / xa._length; - var yFrac = pt[1] / ya._length; + var xFrac = pt[0] / xLen; + var yFrac = pt[1] / yLen; var offScreenFraction = Math.max(0, -xFrac, xFrac - 1, -yFrac, yFrac - 1); if(offScreenFraction && (latestXFrac !== undefined) && crossesViewport(xFrac, yFrac, latestXFrac, latestYFrac) @@ -88,7 +114,7 @@ module.exports = function linePoints(d, opts) { offScreenFraction = 0; } if(offScreenFraction && nextPt && - crossesViewport(xFrac, yFrac, nextPt[0] / xa._length, nextPt[1] / ya._length) + crossesViewport(xFrac, yFrac, nextPt[0] / xLen, nextPt[1] / yLen) ) { offScreenFraction = 0; } @@ -114,10 +140,10 @@ module.exports = function linePoints(d, opts) { // if both are outside there will be 0 or 2 intersections // (or 1 if it's right at a corner - we'll treat that like 0) // returns an array of intersection pts - var xEdge0 = -xa._length * maxScreensAway; - var xEdge1 = xa._length * (1 + maxScreensAway); - var yEdge0 = -ya._length * maxScreensAway; - var yEdge1 = ya._length * (1 + maxScreensAway); + var xEdge0 = -xLen * maxScreensAway; + var xEdge1 = xLen * (1 + maxScreensAway); + var yEdge0 = -yLen * maxScreensAway; + var yEdge1 = yLen * (1 + maxScreensAway); var edges = [ [xEdge0, yEdge0, xEdge1, yEdge0], [xEdge1, yEdge0, xEdge1, yEdge1], @@ -261,8 +287,8 @@ module.exports = function linePoints(d, opts) { } function addPt(pt) { - latestXFrac = pt[0] / xa._length; - latestYFrac = pt[1] / ya._length; + latestXFrac = pt[0] / xLen; + latestYFrac = pt[1] / yLen; // Are we more than maxScreensAway off-screen any direction? // if so, clip to this box, but in such a way that on-screen // drawing is unchanged diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index b0e14b2964a..56980cdcf22 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -386,9 +386,17 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition function visFilter(d) { + return d.filter(function(v) { return !v.gap && v.vis; }); + } + + function visFilterWithGaps(d) { return d.filter(function(v) { return v.vis; }); } + function gapFilter(d) { + return d.filter(function(v) { return !v.gap; }); + } + function keyFunc(d) { return d.id; } @@ -416,12 +424,24 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition var markerFilter = hideFilter; var textFilter = hideFilter; - if(showMarkers) { - markerFilter = (trace.marker.maxdisplayed || trace._needsCull) ? visFilter : Lib.identity; - } + if(showMarkers || showText) { + var showFilter = Lib.identity; + // if we're stacking, "infer zero" gap mode gets markers in the + // gap points - because we've inferred a zero there - but other + // modes (currently "interpolate", later "interrupt" hopefully) + // we don't draw generated markers + var stackGroup = trace.stackgroup; + var isInferZero = stackGroup && ( + gd._fullLayout._scatterStackOpts[xa._id + ya._id][stackGroup].stackgaps === 'infer zero'); + if(trace.marker.maxdisplayed || trace._needsCull) { + showFilter = isInferZero ? visFilterWithGaps : visFilter; + } + else if(stackGroup && !isInferZero) { + showFilter = gapFilter; + } - if(showText) { - textFilter = (trace.marker.maxdisplayed || trace._needsCull) ? visFilter : Lib.identity; + if(showMarkers) markerFilter = showFilter; + if(showText) textFilter = showFilter; } // marker points diff --git a/src/traces/scatter/select.js b/src/traces/scatter/select.js index 5d10050b494..e980a6d7400 100644 --- a/src/traces/scatter/select.js +++ b/src/traces/scatter/select.js @@ -36,9 +36,9 @@ module.exports = function selectPoints(searchInfo, polygon) { x = xa.c2p(di.x); y = ya.c2p(di.y); - if(polygon.contains([x, y])) { + if((di.i !== null) && polygon.contains([x, y])) { selection.push({ - pointNumber: i, + pointNumber: di.i, x: xa.c2d(di.x), y: ya.c2d(di.y) }); diff --git a/src/traces/scatter/stack_defaults.js b/src/traces/scatter/stack_defaults.js new file mode 100644 index 00000000000..26cf2178afc --- /dev/null +++ b/src/traces/scatter/stack_defaults.js @@ -0,0 +1,104 @@ +/** +* 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 perStackAttrs = ['orientation', 'groupnorm', 'stackgaps']; + +module.exports = function handleStackDefaults(traceIn, traceOut, layout, coerce) { + var stackOpts = layout._scatterStackOpts; + + var stackGroup = coerce('stackgroup'); + if(stackGroup) { + // use independent stacking options per subplot + var subplot = traceOut.xaxis + traceOut.yaxis; + var subplotStackOpts = stackOpts[subplot]; + if(!subplotStackOpts) subplotStackOpts = stackOpts[subplot] = {}; + + var groupOpts = subplotStackOpts[stackGroup]; + var firstTrace = false; + if(groupOpts) { + groupOpts.traces.push(traceOut); + } + else { + groupOpts = subplotStackOpts[stackGroup] = { + // keep track of trace indices for use during stacking calculations + // this will be filled in during `calc` and used during `crossTraceCalc` + // so it's OK if we don't recreate it during a non-calc edit + traceIndices: [], + // Hold on to the whole set of prior traces + // First one is most important, so we can clear defaults + // there if we find explicit values only in later traces. + // We're only going to *use* the values stored in groupOpts, + // but for the editor and validate we want things self-consistent + // The full set of traces is used only to fix `fill` default if + // we find `orientation: 'h'` beyond the first trace + traces: [traceOut] + }; + firstTrace = true; + } + // TODO: how is this going to work with groupby transforms? + // in principle it should be OK I guess, as long as explicit group styles + // don't override explicit base-trace styles? + + var dflts = { + orientation: (traceOut.x && !traceOut.y) ? 'h' : 'v' + }; + + for(var i = 0; i < perStackAttrs.length; i++) { + var attr = perStackAttrs[i]; + var attrFound = attr + 'Found'; + if(!groupOpts[attrFound]) { + var traceHasAttr = traceIn[attr] !== undefined; + var isOrientation = attr === 'orientation'; + if(traceHasAttr || firstTrace) { + groupOpts[attr] = coerce(attr, dflts[attr]); + + if(isOrientation) { + groupOpts.fillDflt = groupOpts[attr] === 'h' ? + 'tonextx' : 'tonexty'; + } + + if(traceHasAttr) { + // Note: this will show a value here even if it's invalid + // in which case it will revert to default. + groupOpts[attrFound] = true; + + // Note: only one trace in the stack will get a _fullData + // entry for a given stack-wide attribute. If no traces + // (or the first trace) specify that attribute, the + // first trace will get it. If the first trace does NOT + // specify it but some later trace does, then it gets + // removed from the first trace and only included in the + // one that specified it. This is mostly important for + // editors (that want to see the full values to know + // what settings are available) and Plotly.react diffing. + // Editors may want to use fullLayout._scatterStackOpts + // directly and make these settings available from all + // traces in the stack... then set the new value into + // the first trace, and clear all later traces. + if(!firstTrace) { + delete groupOpts.traces[0][attr]; + + // orientation can affect default fill of previous traces + if(isOrientation) { + for(var j = 0; j < groupOpts.traces.length - 1; j++) { + var trace2 = groupOpts.traces[j]; + if(trace2._input.fill !== trace2.fill) { + trace2.fill = groupOpts.fillDflt; + } + } + } + } + } + } + } + } + return groupOpts; + } +}; diff --git a/src/traces/scattercarpet/attributes.js b/src/traces/scattercarpet/attributes.js index 6b6564d6565..60ca8852fee 100644 --- a/src/traces/scattercarpet/attributes.js +++ b/src/traces/scattercarpet/attributes.js @@ -74,6 +74,7 @@ module.exports = { connectgaps: scatterAttrs.connectgaps, fill: extendFlat({}, scatterAttrs.fill, { values: ['none', 'toself', 'tonext'], + dflt: 'none', description: [ 'Sets the area to fill with a solid color.', 'Use with `fillcolor` if not *none*.', diff --git a/src/traces/scattergl/attributes.js b/src/traces/scattergl/attributes.js index 3d8798115bd..385fb765742 100644 --- a/src/traces/scattergl/attributes.js +++ b/src/traces/scattergl/attributes.js @@ -75,7 +75,7 @@ var attrs = module.exports = overrideAll({ }) }), connectgaps: scatterAttrs.connectgaps, - fill: scatterAttrs.fill, + fill: extendFlat({}, scatterAttrs.fill, {dflt: 'none'}), fillcolor: scatterAttrs.fillcolor, // no hoveron diff --git a/src/traces/scattergl/index.js b/src/traces/scattergl/index.js index befc5e468f5..0bb6dabe015 100644 --- a/src/traces/scattergl/index.js +++ b/src/traces/scattergl/index.js @@ -23,8 +23,10 @@ var findExtremes = require('../../plots/cartesian/autorange').findExtremes; var Color = require('../../components/color'); var subTypes = require('../scatter/subtypes'); -var calcMarkerSize = require('../scatter/calc').calcMarkerSize; -var calcAxisExpansion = require('../scatter/calc').calcAxisExpansion; +var scatterCalc = require('../scatter/calc'); +var calcMarkerSize = scatterCalc.calcMarkerSize; +var calcAxisExpansion = scatterCalc.calcAxisExpansion; +var setFirstScatter = scatterCalc.setFirstScatter; var calcColorscales = require('../scatter/colorscale_calc'); var linkTraces = require('../scatter/link_traces'); var getTraceColor = require('../scatter/get_trace_color'); @@ -89,6 +91,7 @@ function calc(gd, trace) { // Reuse SVG scatter axis expansion routine. // For graphs with very large number of points and array marker.size, // use average marker size instead to speed things up. + setFirstScatter(fullLayout, trace); var ppad; if(count < TOO_MANY_POINTS) { ppad = calcMarkerSize(trace, count); @@ -134,7 +137,6 @@ function calc(gd, trace) { scene.count++; - gd.firstscatter = false; return [{x: false, y: false, t: stash, trace: trace}]; } diff --git a/src/traces/scatterpolar/attributes.js b/src/traces/scatterpolar/attributes.js index e05c8f31da3..fa04df7f593 100644 --- a/src/traces/scatterpolar/attributes.js +++ b/src/traces/scatterpolar/attributes.js @@ -106,6 +106,7 @@ module.exports = { fill: extendFlat({}, scatterAttrs.fill, { values: ['none', 'toself', 'tonext'], + dflt: 'none', description: [ 'Sets the area to fill with a solid color.', 'Use with `fillcolor` if not *none*.', diff --git a/src/traces/scatterternary/attributes.js b/src/traces/scatterternary/attributes.js index e41377fd74e..1dee1c0d70d 100644 --- a/src/traces/scatterternary/attributes.js +++ b/src/traces/scatterternary/attributes.js @@ -103,6 +103,7 @@ module.exports = { cliponaxis: scatterAttrs.cliponaxis, fill: extendFlat({}, scatterAttrs.fill, { values: ['none', 'toself', 'tonext'], + dflt: 'none', description: [ 'Sets the area to fill with a solid color.', 'Use with `fillcolor` if not *none*.', diff --git a/test/image/baselines/autorange-tozero-rangemode.png b/test/image/baselines/autorange-tozero-rangemode.png index 8f52a059a31..c7792350c31 100644 Binary files a/test/image/baselines/autorange-tozero-rangemode.png and b/test/image/baselines/autorange-tozero-rangemode.png differ diff --git a/test/image/baselines/axes_range_type.png b/test/image/baselines/axes_range_type.png index de4d196f356..857b9eec629 100644 Binary files a/test/image/baselines/axes_range_type.png and b/test/image/baselines/axes_range_type.png differ diff --git a/test/image/baselines/contour_log.png b/test/image/baselines/contour_log.png index e5cbbae7e17..e5cb2e20147 100644 Binary files a/test/image/baselines/contour_log.png and b/test/image/baselines/contour_log.png differ diff --git a/test/image/baselines/log_lines_fills.png b/test/image/baselines/log_lines_fills.png new file mode 100644 index 00000000000..9aa6c95be5b Binary files /dev/null and b/test/image/baselines/log_lines_fills.png differ diff --git a/test/image/baselines/stacked_area.png b/test/image/baselines/stacked_area.png new file mode 100644 index 00000000000..2f508049fbc Binary files /dev/null and b/test/image/baselines/stacked_area.png differ diff --git a/test/image/baselines/stacked_area_duplicates.png b/test/image/baselines/stacked_area_duplicates.png new file mode 100644 index 00000000000..3b35f75ba3c Binary files /dev/null and b/test/image/baselines/stacked_area_duplicates.png differ diff --git a/test/image/baselines/stacked_area_horz.png b/test/image/baselines/stacked_area_horz.png new file mode 100644 index 00000000000..3c18529b36d Binary files /dev/null and b/test/image/baselines/stacked_area_horz.png differ diff --git a/test/image/baselines/stacked_area_log.png b/test/image/baselines/stacked_area_log.png new file mode 100644 index 00000000000..737318747aa Binary files /dev/null and b/test/image/baselines/stacked_area_log.png differ diff --git a/test/image/mocks/log_lines_fills.json b/test/image/mocks/log_lines_fills.json new file mode 100644 index 00000000000..b42a1b41d10 --- /dev/null +++ b/test/image/mocks/log_lines_fills.json @@ -0,0 +1,33 @@ +{ + "data": [{ + "x": [-1, 1.5, 2, 0, 1, 3, 4, 5, 6, 1, -1, -1, 3, 3, 0, 7, 7, -1, -1], + "y": [-1, 1.5, 1, -1, -1, 1, 1, 0, 2, 2, 0, 2, 3, 4, 5, 6, -2, -2, -1], + "mode": "markers+lines", "fill": "toself" + }, { + "x": [-1, 1.5, 2, 0, 1, 3, 4, 5, 6, 1, -1, -1, 3, 3, 0, 7, 7, -1, -1], + "y": [-1, 1.5, 1, -1, -1, 1, 1, 0, 2, 2, 0, 2, 3, 4, 5, 6, -2, -2, -1], + "mode": "markers+lines", "fill": "toself", "xaxis": "x2" + }, { + "x": [-1, 1.5, 2, 0, 1, 3, 4, 5, 6, 1, -1, -1, 3, 3, 0, 7, 7, -1, -1], + "y": [-1, 1.5, 1, -1, -1, 1, 1, 0, 2, 2, 0, 2, 3, 4, 5, 6, -2, -2, -1], + "mode": "markers+lines", "fill": "toself", "yaxis": "y2" + }, { + "x": [-1, 1.5, 2, 0, 1, 3, 4, 5, 6, 1, -1, -1, 3, 3, 0, 7, 7, -1, -1], + "y": [-1, 1.5, 1, -1, -1, 1, 1, 0, 2, 2, 0, 2, 3, 4, 5, 6, -2, -2, -1], + "mode": "markers+lines", "fill": "toself", "xaxis": "x2", "yaxis": "y2" + }, { + "x": [0.01, 0.1], "y": [0.01, 0.1], + "mode": "markers", "xaxis": "x2", "yaxis": "y2" + }], + "layout": { + "width": 800, + "height": 600, + "xaxis": {"title": "linear"}, + "xaxis2": {"title": "log", "type": "log"}, + "yaxis": {"title": "linear"}, + "yaxis2": {"title": "log", "type": "log"}, + "grid": {"columns": 2, "rows": 2}, + "showlegend": false, + "title": "Lines to invalid log values
4 copies of the same self-filled trace, on all combinations of log & linear axes
Purple points should lie exactly on an off-to-infinity log-log line to verify that its slope is 1" + } +} diff --git a/test/image/mocks/scatter_fill_corner_cases.json b/test/image/mocks/scatter_fill_corner_cases.json index febf2052b23..1240ce8240f 100644 --- a/test/image/mocks/scatter_fill_corner_cases.json +++ b/test/image/mocks/scatter_fill_corner_cases.json @@ -75,7 +75,6 @@ { "x": [1.5], "y": [1.25], - "fill": "tonexty", "showlegend": false, "yaxis": "y2" }, @@ -111,7 +110,6 @@ { "x": [1.5], "y": [1.25], - "fill": "tonexty", "line": {"shape": "spline"}, "xaxis": "x2", "showlegend": false, diff --git a/test/image/mocks/stacked_area.json b/test/image/mocks/stacked_area.json new file mode 100644 index 00000000000..0c2aac3076b --- /dev/null +++ b/test/image/mocks/stacked_area.json @@ -0,0 +1,83 @@ +{ + "data": [ + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x2", "yaxis": "y2", + "stackgaps": "interpolate" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x2", "yaxis": "y2" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x2", "yaxis": "y2" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "b", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers", + "groupnorm": "fraction" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "b", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "b", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers", + "stackgaps": "interpolate", "groupnorm": "fraction" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x5", "yaxis": "y5" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x5", "yaxis": "y5" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x5", "yaxis": "y5", + "groupnorm": "percent" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x6", "yaxis": "y6", + "groupnorm": "percent" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x6", "yaxis": "y6" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x6", "yaxis": "y6", + "stackgaps": "interpolate" + } + ], + "layout": { + "width": 800, + "height": 800, + "xaxis": {"title": "stackgaps: infer zero"}, + "xaxis2": {"title": "stackgaps: interpolate"}, + "yaxis": {"title": "groupnorm: -"}, + "yaxis3": {"title": "groupnorm: fraction
mode: lines+markers"}, + "yaxis5": {"title": "groupnorm: percent"}, + "legend": {"traceorder": "reversed"}, + "grid": {"columns": 2, "rows": 3, "pattern": "independent", "roworder": "bottom to top"} + } +} diff --git a/test/image/mocks/stacked_area_duplicates.json b/test/image/mocks/stacked_area_duplicates.json new file mode 100644 index 00000000000..1f013ba231f --- /dev/null +++ b/test/image/mocks/stacked_area_duplicates.json @@ -0,0 +1,42 @@ +{ + "data": [ + { + "x": [1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 6, 8], + "y": [1, 3, 2, 5, 4, 5, 4, 3, 2, 4, 6, 3, 5, 4, 3], + "stackgroup": "a", "mode": "lines+markers" + }, { + "x": [1, 2, 2, 2, 2, 3, 5, 5, 5, 5, 6, 6, 6, 6, 8], + "y": [4, 4, 4, 4, 6, 5, 6, 5, 7, 6, 5, 6, 7, 8, 7], + "stackgroup": "a", "mode": "lines+markers" + }, { + "x": [2, 2, 2, 2, 3, 4, 4, 4, 4, 5, 7, 7, 7, 7, 8], + "y": [5, 5, 4, 5, 6, 7, 6, 5, 4, 5, 4, 5, 6, 3, 4], + "stackgroup": "a", "mode": "lines+markers" + }, + + { + "x": [1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 6, 8], + "y": [1, 3, 2, 5, 4, 5, 4, 3, 2, 4, 6, 3, 5, 4, 3], + "stackgroup": "a", "mode": "lines+markers", + "xaxis": "x2", "yaxis": "y2", "stackgaps": "interpolate" + }, { + "x": [1, 2, 2, 2, 2, 3, 5, 5, 5, 5, 6, 6, 6, 6, 8], + "y": [4, 4, 4, 4, 6, 5, 6, 5, 7, 6, 5, 6, 7, 8, 7], + "stackgroup": "a", "mode": "lines+markers", + "xaxis": "x2", "yaxis": "y2", "stackgaps": "interpolate" + }, { + "x": [2, 2, 2, 2, 3, 4, 4, 4, 4, 5, 7, 7, 7, 7, 8], + "y": [5, 5, 4, 5, 6, 7, 6, 5, 4, 5, 4, 5, 6, 3, 4], + "stackgroup": "a", "mode": "lines+markers", + "xaxis": "x2", "yaxis": "y2", "stackgaps": "interpolate" + } + ], + "layout": { + "width": 500, + "height": 500, + "title": "Duplicate positions", + "xaxis": {"title": "infer zero"}, + "xaxis2": {"title": "interpolate"}, + "grid": {"columns": 1, "rows": 2, "pattern": "independent"} + } +} diff --git a/test/image/mocks/stacked_area_horz.json b/test/image/mocks/stacked_area_horz.json new file mode 100644 index 00000000000..4d0342996c8 --- /dev/null +++ b/test/image/mocks/stacked_area_horz.json @@ -0,0 +1,83 @@ +{ + "data": [ + { + "y": [1, 3, 5, 7], "x": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "orientation": "h", "name": "bottom", "legendgroup": "b" + }, { + "y": [2, 5, 6], "x": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m" + }, { + "y": [3, 4 ,5], "x": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t" + }, + + { + "y": [1, 3, 5, 7], "x": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x2", "yaxis": "y2", + "stackgaps": "interpolate" + }, { + "y": [2, 5, 6], "x": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "orientation": "h", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x2", "yaxis": "y2" + }, { + "y": [3, 4 ,5], "x": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x2", "yaxis": "y2" + }, + + { + "y": [1, 3, 5, 7], "x": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "b", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers", + "groupnorm": "fraction" + }, { + "y": [2, 5, 6], "x": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "b", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers" + }, { + "y": [3, 4 ,5], "x": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "b", "orientation": "h", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers" + }, + + { + "y": [1, 3, 5, 7], "x": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "orientation": "h", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers" + }, { + "y": [2, 5, 6], "x": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers", + "stackgaps": "interpolate", "groupnorm": "fraction" + }, { + "y": [3, 4 ,5], "x": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers" + }, + + { + "y": [1, 3, 5, 7], "x": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x5", "yaxis": "y5" + }, { + "y": [2, 5, 6], "x": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "orientation": "h", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x5", "yaxis": "y5" + }, { + "y": [3, 4 ,5], "x": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x5", "yaxis": "y5", + "groupnorm": "percent" + }, + + { + "y": [1, 3, 5, 7], "x": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x6", "yaxis": "y6", + "groupnorm": "percent" + }, { + "y": [2, 5, 6], "x": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x6", "yaxis": "y6" + }, { + "y": [3, 4 ,5], "x": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "orientation": "h", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x6", "yaxis": "y6", + "stackgaps": "interpolate" + } + ], + "layout": { + "width": 800, + "height": 800, + "xaxis": {"title": "stackgaps: infer zero"}, + "xaxis2": {"title": "stackgaps: interpolate"}, + "yaxis": {"title": "groupnorm: -"}, + "yaxis3": {"title": "groupnorm: fraction
mode: lines+markers"}, + "yaxis5": {"title": "groupnorm: percent"}, + "legend": {"traceorder": "reversed"}, + "grid": {"columns": 2, "rows": 3, "pattern": "independent", "roworder": "bottom to top"} + } +} diff --git a/test/image/mocks/stacked_area_log.json b/test/image/mocks/stacked_area_log.json new file mode 100644 index 00000000000..ec0965a01d3 --- /dev/null +++ b/test/image/mocks/stacked_area_log.json @@ -0,0 +1,86 @@ +{ + "data": [ + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x2", "yaxis": "y2", + "stackgaps": "interpolate" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x2", "yaxis": "y2" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x2", "yaxis": "y2" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "b", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers", + "groupnorm": "fraction" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "b", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "b", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers", + "stackgaps": "interpolate", "groupnorm": "fraction" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x5", "yaxis": "y5" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x5", "yaxis": "y5" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x5", "yaxis": "y5", + "groupnorm": "percent" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x6", "yaxis": "y6", + "groupnorm": "percent" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x6", "yaxis": "y6" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x6", "yaxis": "y6", + "stackgaps": "interpolate" + } + ], + "layout": { + "width": 800, + "height": 800, + "xaxis": {"title": "stackgaps: infer zero"}, + "xaxis2": {"title": "stackgaps: interpolate"}, + "yaxis": {"title": "groupnorm: -", "type": "log"}, + "yaxis2": {"type": "log"}, + "yaxis3": {"title": "groupnorm: fraction
mode: lines+markers", "type": "log"}, + "yaxis4": {"type": "log"}, + "yaxis5": {"title": "groupnorm: percent", "type": "log"}, + "yaxis6": {"type": "log"}, + "legend": {"traceorder": "reversed"}, + "grid": {"columns": 2, "rows": 3, "pattern": "independent", "roworder": "bottom to top"} + } +} diff --git a/test/jasmine/assets/custom_assertions.js b/test/jasmine/assets/custom_assertions.js index e288128f303..aab46b67f5e 100644 --- a/test/jasmine/assets/custom_assertions.js +++ b/test/jasmine/assets/custom_assertions.js @@ -122,6 +122,7 @@ exports.assertHoverLabelContent = function(expectation, msg) { expect(ptCnt) .toBe(expectation.name.length, ptMsg + ' # of visible labels'); + var bboxes = []; d3.selectAll(ptSelector).each(function(_, i) { assertLabelContent( d3.select(this).select('text.nums'), @@ -133,7 +134,20 @@ exports.assertHoverLabelContent = function(expectation, msg) { expectation.name[i], ptMsg + ' (name ' + i + ')' ); + bboxes.push({bbox: this.getBoundingClientRect(), index: i}); }); + if(expectation.vOrder) { + bboxes.sort(function(a, b) { + return (a.bbox.top + a.bbox.bottom - b.bbox.top - b.bbox.bottom) / 2; + }); + expect(bboxes.map(function(d) { return d.index; })).toEqual(expectation.vOrder); + } + if(expectation.hOrder) { + bboxes.sort(function(a, b) { + return (b.bbox.left + b.bbox.right - a.bbox.left - a.bbox.right) / 2; + }); + expect(bboxes.map(function(d) { return d.index; })).toEqual(expectation.hOrder); + } } else { if(expectation.nums) { fail(ptMsg + ': expecting *nums* labels, did not find any.'); diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index a5d91c7718e..bfc15effcc0 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -403,6 +403,24 @@ describe('Test axes', function() { }); }); + it('only allows rangemode with linear axes', function() { + layoutIn = { + xaxis: {type: 'log', rangemode: 'tozero'}, + yaxis: {type: 'date', rangemode: 'tozero'}, + xaxis2: {type: 'category', rangemode: 'tozero'}, + yaxis2: {type: 'linear', rangemode: 'tozero'} + }; + layoutOut._subplots.cartesian.push('x2y2'); + layoutOut._subplots.yaxis.push('x2', 'y2'); + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + + expect(layoutOut.xaxis.rangemode).toBeUndefined(); + expect(layoutOut.yaxis.rangemode).toBeUndefined(); + expect(layoutOut.xaxis2.rangemode).toBeUndefined(); + expect(layoutOut.yaxis2.rangemode).toBe('tozero'); + }); + it('finds scaling groups and calculates relative scales', function() { layoutIn = { // first group: linked in series, scales compound @@ -1558,7 +1576,7 @@ describe('Test axes', function() { expect(getAutoRange(gd, ax)).toEqual([7.5, 0]); }); - it('expands empty positive range to something including 0 with rangemode tozero', function() { + it('expands empty positive range to include 0 with rangemode tozero', function() { gd = mockGd([ {val: 5, pad: 0} ], [ @@ -1567,7 +1585,7 @@ describe('Test axes', function() { ax = mockAx(); ax.rangemode = 'tozero'; - expect(getAutoRange(gd, ax)).toEqual([0, 6]); + expect(getAutoRange(gd, ax)).toEqual([0, 5]); }); it('expands empty negative range to something including 0 with rangemode tozero', function() { @@ -1579,7 +1597,63 @@ describe('Test axes', function() { ax = mockAx(); ax.rangemode = 'tozero'; - expect(getAutoRange(gd, ax)).toEqual([-6, 0]); + expect(getAutoRange(gd, ax)).toEqual([-5, 0]); + }); + + it('pads an empty range, but not past center, with rangemode tozero', function() { + gd = mockGd([ + {val: 5, pad: 50} // this min pad gets ignored + ], [ + {val: 5, pad: 20} + ]); + ax = mockAx(); + ax.rangemode = 'tozero'; + + expect(getAutoRange(gd, ax)).toBeCloseToArray([0, 6.25], 0.01); + + gd = mockGd([ + {val: -5, pad: 80} + ], [ + {val: -5, pad: 0} + ]); + ax = mockAx(); + ax.rangemode = 'tozero'; + + expect(getAutoRange(gd, ax)).toBeCloseToArray([-10, 0], 0.01); + }); + + it('shows the data even if it cannot show the padding', function() { + gd = mockGd([ + {val: 0, pad: 44} + ], [ + {val: 1, pad: 44} + ]); + ax = mockAx(); + + // this one is *just* on the allowed side of padding + // ie data span is just over 10% of the axis + expect(getAutoRange(gd, ax)).toBeCloseToArray([-3.67, 4.67]); + + gd = mockGd([ + {val: 0, pad: 46} + ], [ + {val: 1, pad: 46} + ]); + ax = mockAx(); + + // this one the padded data span would be too small, so we delete + // the padding + expect(getAutoRange(gd, ax)).toEqual([0, 1]); + + gd = mockGd([ + {val: 0, pad: 400} + ], [ + {val: 1, pad: 0} + ]); + ax = mockAx(); + + // this one the padding is simply impossible to accept! + expect(getAutoRange(gd, ax)).toEqual([0, 1]); }); it('never returns a negative range when rangemode nonnegative is set with positive and negative points', function() { @@ -1614,17 +1688,43 @@ describe('Test axes', function() { expect(getAutoRange(gd, ax)).toEqual([0, 1]); }); - it('expands empty range to something nonnegative with rangemode nonnegative', function() { + it('never returns a negative range when rangemode nonnegative is set with only nonpositive points', function() { gd = mockGd([ - {val: -5, pad: 0} + {val: -10, pad: 20}, + {val: -8, pad: 0}, + {val: -9, pad: 10} ], [ - {val: -5, pad: 0} + {val: -5, pad: 20}, + {val: 0, pad: 0}, + {val: -6, pad: 10} ]); ax = mockAx(); ax.rangemode = 'nonnegative'; expect(getAutoRange(gd, ax)).toEqual([0, 1]); }); + + it('expands empty range to something nonnegative with rangemode nonnegative', function() { + [ + [-5, [0, 1]], + [0, [0, 1]], + [0.5, [0, 1.5]], + [1, [0, 2]], + [5, [4, 6]] + ].forEach(function(testCase) { + var val = testCase[0]; + var expected = testCase[1]; + gd = mockGd([ + {val: val, pad: 0} + ], [ + {val: val, pad: 0} + ]); + ax = mockAx(); + ax.rangemode = 'nonnegative'; + + expect(getAutoRange(gd, ax)).toEqual(expected, val); + }); + }); }); describe('findExtremes', function() { diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index 7dc0edf0feb..aa0981c5ac3 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -520,7 +520,92 @@ describe('hover info', function() { Lib.clearThrottle(); } - describe('\'hover info for x/y/z traces', function() { + describe('hover label order for stacked traces with zeros', function() { + var gd; + beforeEach(function() { + gd = createGraphDiv(); + }); + + it('puts the top trace on top', function(done) { + Plotly.plot(gd, [ + {y: [1, 2, 3], type: 'bar', name: 'a'}, + {y: [2, 0, 1], type: 'bar', name: 'b'}, + {y: [1, 0, 1], type: 'bar', name: 'c'}, + {y: [2, 1, 0], type: 'bar', name: 'd'} + ], { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0}, + barmode: 'stack' + }) + .then(function() { + _hover(gd, 250, 250); + assertHoverLabelContent({ + nums: ['2', '0', '0', '1'], + name: ['a', 'b', 'c', 'd'], + // a, b, c are all in the same place but keep their order + // d is included mostly as a sanity check + vOrder: [3, 2, 1, 0], + axis: '1' + }); + + // reverse the axis, labels should reverse + return Plotly.relayout(gd, 'yaxis.range', gd.layout.yaxis.range.slice().reverse()); + }) + .then(function() { + _hover(gd, 250, 250); + assertHoverLabelContent({ + nums: ['2', '0', '0', '1'], + name: ['a', 'b', 'c', 'd'], + vOrder: [0, 1, 2, 3], + axis: '1' + }); + }) + .catch(failTest) + .then(done); + }); + + it('puts the right trace on the right', function(done) { + Plotly.plot(gd, [ + {x: [1, 2, 3], type: 'bar', name: 'a', orientation: 'h'}, + {x: [2, 0, 1], type: 'bar', name: 'b', orientation: 'h'}, + {x: [1, 0, 1], type: 'bar', name: 'c', orientation: 'h'}, + {x: [2, 1, 0], type: 'bar', name: 'd', orientation: 'h'} + ], { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0}, + barmode: 'stack' + }) + .then(function() { + _hover(gd, 250, 250); + assertHoverLabelContent({ + nums: ['2', '0', '0', '1'], + name: ['a', 'b', 'c', 'd'], + // a, b, c are all in the same place but keep their order + // d is included mostly as a sanity check + hOrder: [3, 2, 1, 0], + axis: '1' + }); + + // reverse the axis, labels should reverse + return Plotly.relayout(gd, 'xaxis.range', gd.layout.xaxis.range.slice().reverse()); + }) + .then(function() { + _hover(gd, 250, 250); + assertHoverLabelContent({ + nums: ['2', '0', '0', '1'], + name: ['a', 'b', 'c', 'd'], + hOrder: [0, 1, 2, 3], + axis: '1' + }); + }) + .catch(failTest) + .then(done); + }); + }); + + describe('hover info for x/y/z traces', function() { var gd; beforeEach(function() { gd = createGraphDiv(); diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index dffebe8ae9a..e4ed92e3a3c 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -2189,6 +2189,164 @@ describe('Test lib.js:', function() { }); }); + describe('sort', function() { + var callCount; + beforeEach(function() { + callCount = 0; + }); + + function sortCounter(a, b) { + callCount++; + return a - b; + } + + function sortCounterReversed(a, b) { + callCount++; + return b - a; + } + + function ascending(n) { + var out = new Array(n); + for(var i = 0; i < n; i++) { + out[i] = i; + } + assertAscending(out); + return out; + } + + function descending(n) { + var out = new Array(n); + for(var i = 0; i < n; i++) { + out[i] = n - 1 - i; + } + assertDescending(out); + return out; + } + + function rand(n) { + Lib.seedPseudoRandom(); + var out = new Array(n); + for(var i = 0; i < n; i++) { + out[i] = Lib.pseudoRandom(); + } + return out; + } + + function assertAscending(array) { + for(var i = 1; i < array.length; i++) { + if(array[i] < array[i - 1]) { + // we already know this expect will fail, + // just want to format the message nicely and then + // quit so we don't get a million messages + expect(array[i]).not.toBeLessThan(array[i - 1]); + break; + } + } + } + + function assertDescending(array) { + for(var i = 1; i < array.length; i++) { + if(array[i] < array[i - 1]) { + expect(array[i]).not.toBeGreaterThan(array[i - 1]); + break; + } + } + } + + function _sort(array, sortFn) { + var arrayOut = Lib.sort(array, sortFn); + expect(arrayOut).toBe(array); + return array; + } + + it('sorts ascending arrays ascending in N-1 calls', function() { + var arrayIn = _sort(ascending(100000), sortCounter); + expect(callCount).toBe(99999); + assertAscending(arrayIn); + }); + + it('sorts descending arrays ascending in N-1 calls', function() { + var arrayIn = _sort(descending(100000), sortCounter); + expect(callCount).toBe(99999); + assertAscending(arrayIn); + }); + + it('sorts ascending arrays descending in N-1 calls', function() { + var arrayIn = _sort(ascending(100000), sortCounterReversed); + expect(callCount).toBe(99999); + assertDescending(arrayIn); + }); + + it('sorts descending arrays descending in N-1 calls', function() { + var arrayIn = _sort(descending(100000), sortCounterReversed); + expect(callCount).toBe(99999); + assertDescending(arrayIn); + }); + + it('sorts random arrays ascending in a few more calls than bare sort', function() { + var arrayIn = _sort(rand(100000), sortCounter); + assertAscending(arrayIn); + + var ourCallCount = callCount; + callCount = 0; + rand(100000).sort(sortCounter); + // in general this will be ~N*log_2(N) + expect(callCount).toBeGreaterThan(1e6); + // This number (2) is only repeatable because we used Lib.pseudoRandom + // should always be at least 2 and less than N - 1, and if + // the input array is really not sorted it will be close to 2. It will + // only be large if the array is sorted until near the end. + expect(ourCallCount - callCount).toBe(2); + }); + + it('sorts random arrays descending in a few more calls than bare sort', function() { + var arrayIn = _sort(rand(100000), sortCounterReversed); + assertDescending(arrayIn); + + var ourCallCount = callCount; + callCount = 0; + rand(100000).sort(sortCounterReversed); + expect(callCount).toBeGreaterThan(1e6); + expect(ourCallCount - callCount).toBe(2); + }); + + it('supports short arrays', function() { + expect(_sort([], sortCounter)).toEqual([]); + expect(_sort([1], sortCounter)).toEqual([1]); + expect(callCount).toBe(0); + + expect(_sort([1, 2], sortCounter)).toEqual([1, 2]); + expect(_sort([2, 3], sortCounterReversed)).toEqual([3, 2]); + expect(callCount).toBe(2); + }); + + function dupes() { + return [0, 1, 1, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 5, 5, 6, 7, 8, 9]; + } + + it('still short-circuits in order with duplicates', function() { + expect(_sort(dupes(), sortCounter)) + .toEqual(dupes()); + + expect(callCount).toEqual(18); + + callCount = 0; + dupes().sort(sortCounter); + expect(callCount).toBeGreaterThan(18); + }); + + it('still short-circuits reversed with duplicates', function() { + expect(_sort(dupes(), sortCounterReversed)) + .toEqual(dupes().reverse()); + + expect(callCount).toEqual(18); + + callCount = 0; + dupes().sort(sortCounterReversed); + expect(callCount).toBeGreaterThan(18); + }); + }); + describe('relinkPrivateKeys', function() { it('ignores customdata and ids', function() { var fromContainer = { diff --git a/test/jasmine/tests/scatter_test.js b/test/jasmine/tests/scatter_test.js index dffed81956d..74817411abf 100644 --- a/test/jasmine/tests/scatter_test.js +++ b/test/jasmine/tests/scatter_test.js @@ -832,6 +832,32 @@ describe('end-to-end scatter tests', function() { .then(done); }); + it('correctly autoranges fill tonext traces across multiple subplots', function(done) { + Plotly.newPlot(gd, [ + {y: [3, 4, 5], fill: 'tonexty'}, + {y: [4, 5, 6], fill: 'tonexty'}, + {y: [3, 4, 5], fill: 'tonexty', yaxis: 'y2'}, + {y: [4, 5, 6], fill: 'tonexty', yaxis: 'y2'} + ], {}) + .then(function() { + expect(gd._fullLayout.yaxis.range[0]).toBe(0); + // when we had a single `gd.firstscatter` this one was ~2.73 + // even though the fill was correctly drawn down to zero + expect(gd._fullLayout.yaxis2.range[0]).toBe(0); + }) + .catch(failTest) + .then(done); + }); + + it('correctly autoranges fill tonext traces with only one point', function(done) { + Plotly.newPlot(gd, [{y: [3], fill: 'tonexty'}]) + .then(function() { + expect(gd._fullLayout.yaxis.range[0]).toBe(0); + }) + .catch(failTest) + .then(done); + }); + it('should work with typed arrays', function(done) { function _assert(colors, sizes) { var pts = d3.selectAll('.point'); @@ -978,6 +1004,129 @@ describe('end-to-end scatter tests', function() { }); }); +describe('stacked area', function() { + var gd; + + beforeEach(function() { gd = createGraphDiv(); }); + afterEach(destroyGraphDiv); + var mock = require('@mocks/stacked_area'); + + it('updates ranges correctly when traces are toggled', function(done) { + function checkRanges(ranges, msg) { + for(var axId in ranges) { + var axName = axId.charAt(0) + 'axis' + axId.slice(1); + expect(gd._fullLayout[axName].range) + .toBeCloseToArray(ranges[axId], 0.1, msg + ' - ' + axId); + } + } + Plotly.newPlot(gd, Lib.extendDeep({}, mock)) + .then(function() { + // initial ranges, as in the baseline image + var xr = [1, 7]; + checkRanges({ + x: xr, x2: xr, x3: xr, x4: xr, x5: xr, x6: xr, + y: [0, 8.42], y2: [0, 10.53], + // TODO: for normalized data, perhaps we want to + // remove padding from the top (like we do from the zero) + // when data stay within the normalization limit? + // (y3&4 are more padded because they have markers) + y3: [0, 1.08], y4: [0, 1.08], y5: [0, 105.26], y6: [0, 105.26] + }, 'base case'); + + return Plotly.restyle(gd, 'visible', 'legendonly', [0, 3, 6, 9, 12, 15]); + }) + .then(function() { + var xr = [2, 6]; + checkRanges({ + x: xr, x2: xr, x3: xr, x4: xr, x5: xr, x6: xr, + y: [0, 4.21], y2: [0, 5.26], + y3: [0, 1.08], y4: [0, 1.08], y5: [0, 105.26], y6: [0, 105.26] + }, 'bottom trace legendonly'); + + return Plotly.restyle(gd, 'visible', false, [0, 3, 6, 9, 12, 15]); + }) + .then(function() { + var xr = [2, 6]; + checkRanges({ + x: xr, x2: xr, x3: xr, x4: xr, x5: xr, x6: xr, + // now we lose the explicit config from the bottom trace, + // which we kept when it was visible: 'legendonly' + y: [0, 4.21], y2: [0, 4.21], + y3: [0, 4.32], y4: [0, 1.08], y5: [0, 105.26], y6: [0, 5.26] + }, 'bottom trace visible: false'); + + // put the bottom traces back to legendonly so they still contribute + // config attributes, and hide the middles too + return Plotly.restyle(gd, 'visible', 'legendonly', + [0, 3, 6, 9, 12, 15, 1, 4, 7, 10, 13, 16]); + }) + .then(function() { + var xr = [3, 5]; + checkRanges({ + x: xr, x2: xr, x3: xr, x4: xr, x5: xr, x6: xr, + y: [0, 2.11], y2: [0, 2.11], + y3: [0, 1.08], y4: [0, 1.08], y5: [0, 105.26], y6: [0, 105.26] + }, 'only top trace showing'); + + return Plotly.restyle(gd, 'visible', true, [0, 3, 6, 9, 12, 15]); + }) + .then(function() { + var xr = [1, 7]; + checkRanges({ + x: xr, x2: xr, x3: xr, x4: xr, x5: xr, x6: xr, + y: [0, 7.37], y2: [0, 7.37], + y3: [0, 1.08], y4: [0, 1.08], y5: [0, 105.26], y6: [0, 105.26] + }, 'top and bottom showing'); + + return Plotly.restyle(gd, {x: null, y: null}, [0, 3, 6, 9, 12, 15]); + }) + .then(function() { + return Plotly.restyle(gd, 'visible', true, [1, 4, 7, 10, 13, 16]); + }) + .then(function() { + var xr = [2, 6]; + // an invalid trace (no data) implicitly has visible: false, and is + // equivalent to explicit visible: false in removing stack config. + checkRanges({ + x: xr, x2: xr, x3: xr, x4: xr, x5: xr, x6: xr, + y: [0, 4.21], y2: [0, 4.21], + y3: [0, 4.32], y4: [0, 1.08], y5: [0, 105.26], y6: [0, 5.26] + }, 'bottom trace *implicit* visible: false'); + }) + .catch(failTest) + .then(done); + }); + + it('does not stack on date axes', function(done) { + Plotly.newPlot(gd, [ + {y: ['2016-01-01', '2017-01-01'], stackgroup: 'a'}, + {y: ['2016-01-01', '2017-01-01'], stackgroup: 'a'} + ]) + .then(function() { + expect(gd.layout.yaxis.range.map(function(v) { return v.slice(0, 4); })) + // if we had stacked, this would go into the 2060s since we'd be + // adding milliseconds since 1970 + .toEqual(['2015', '2017']); + }) + .catch(failTest) + .then(done); + }); + + it('does not stack on category axes', function(done) { + Plotly.newPlot(gd, [ + {y: ['a', 'b'], stackgroup: 'a'}, + {y: ['b', 'c'], stackgroup: 'a'} + ]) + .then(function() { + // if we had stacked, we'd calculate a new category 3 + // and autorange to ~[-0.2, 3.2] + expect(gd.layout.yaxis.range).toBeCloseToArray([-0.1, 2.1], 1); + }) + .catch(failTest) + .then(done); + }); +}); + describe('scatter hoverPoints', function() { afterEach(destroyGraphDiv);