diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 4c606c3bcba..2419a58af2b 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -144,7 +144,8 @@ exports.loneHover = function loneHover(hoverItem, opts) { rotateLabels: false, bgColor: opts.bgColor || Color.background, container: container3, - outerContainer: outerContainer3 + outerContainer: outerContainer3, + hoverdistance: constants.MAXDIST }; var hoverLabel = createHoverText([pointData], fullOpts, opts.gd); @@ -207,6 +208,9 @@ function _hover(gd, evt, subplot, noHoverEvent) { return dragElement.unhoverRaw(gd, evt); } + var hoverdistance = fullLayout.hoverdistance === -1 ? Infinity : fullLayout.hoverdistance; + var spikedistance = fullLayout.spikedistance === -1 ? Infinity : fullLayout.spikedistance; + // hoverData: the set of candidate points we've found to highlight var hoverData = [], @@ -232,7 +236,13 @@ function _hover(gd, evt, subplot, noHoverEvent) { xval, yval, pointData, - closedataPreviousLength; + closedataPreviousLength, + + // spikePoints: the set of candidate points we've found to draw spikes to + spikePoints = { + hLinePoint: null, + vLinePoint: null + }; // Figure out what we're hovering on: // mouse location or user-supplied data @@ -258,15 +268,18 @@ 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; if(hasUserCalledHover) { if('xpx' in evt) xpx = evt.xpx; else xpx = xaArray[0]._length / 2; + evt.pointerX = xpx + xaArray[0]._offset; if('ypx' in evt) ypx = evt.ypx; else ypx = yaArray[0]._length / 2; + evt.pointerY = ypx + yaArray[0]._offset; } else { // fire the beforehover event and quit if it returns false @@ -286,6 +299,8 @@ function _hover(gd, evt, subplot, noHoverEvent) { if(xpx < 0 || xpx > dbb.width || ypx < 0 || ypx > dbb.height) { return dragElement.unhoverRaw(gd, evt); } + evt.pointerX = evt.offsetX; + evt.pointerY = evt.offsetY; } if('xval' in evt) xvalArray = helpers.flat(subplots, evt.xval); @@ -334,7 +349,7 @@ function _hover(gd, evt, subplot, noHoverEvent) { ya: yaArray[subploti], // point properties - override all of these index: false, // point index in trace - only used by plotly.js hoverdata consumers - distance: Math.min(distance, constants.MAXDIST), // pixel distance or pseudo-distance + distance: Math.min(distance, hoverdistance), // pixel distance or pseudo-distance color: Color.defaultLine, // trace color name: trace.name, x0: undefined, @@ -379,21 +394,23 @@ function _hover(gd, evt, subplot, noHoverEvent) { yval = yvalArray[subploti]; } - // Now find the points. - if(trace._module && trace._module.hoverPoints) { - var newPoints = trace._module.hoverPoints(pointData, xval, yval, mode, fullLayout._hoverlayer); - if(newPoints) { - var newPoint; - for(var newPointNum = 0; newPointNum < newPoints.length; newPointNum++) { - newPoint = newPoints[newPointNum]; - if(isNumeric(newPoint.x0) && isNumeric(newPoint.y0)) { - hoverData.push(cleanPoint(newPoint, hovermode)); + // Now if there is range to look in, find the points to hover. + if(hoverdistance !== 0) { + if(trace._module && trace._module.hoverPoints) { + var newPoints = trace._module.hoverPoints(pointData, xval, yval, mode, fullLayout._hoverlayer); + if(newPoints) { + var newPoint; + for(var newPointNum = 0; newPointNum < newPoints.length; newPointNum++) { + newPoint = newPoints[newPointNum]; + if(isNumeric(newPoint.x0) && isNumeric(newPoint.y0)) { + hoverData.push(cleanPoint(newPoint, hovermode)); + } } } } - } - else { - Lib.log('Unrecognized trace type in hover:', trace); + else { + Lib.log('Unrecognized trace type in hover:', trace); + } } // in closest mode, remove any existing (farther) points @@ -402,10 +419,140 @@ function _hover(gd, evt, subplot, noHoverEvent) { hoverData.splice(0, closedataPreviousLength); distance = hoverData[0].distance; } + + // Now if there is range to look in, find the points to draw the spikelines + // Do it only if there is no hoverData + if(fullLayout._has('cartesian') && (spikedistance !== 0)) { + if(hoverData.length === 0) { + pointData.distance = spikedistance; + pointData.index = false; + var closestPoints = trace._module.hoverPoints(pointData, xval, yval, 'closest', fullLayout._hoverlayer); + if(closestPoints) { + var tmpPoint; + var closestVPoints = closestPoints.filter(function(point) { + return point.xa.showspikes; + }); + if(closestVPoints.length) { + var closestVPt = closestVPoints[0]; + if(isNumeric(closestVPt.x0) && isNumeric(closestVPt.y0)) { + tmpPoint = fillClosestPoint(closestVPt); + if(!spikePoints.vLinePoint || (spikePoints.vLinePoint.distance > tmpPoint.distance)) { + spikePoints.vLinePoint = tmpPoint; + } + } + } + + var closestHPoints = closestPoints.filter(function(point) { + return point.ya.showspikes; + }); + if(closestHPoints.length) { + var closestHPt = closestHPoints[0]; + if(isNumeric(closestHPt.x0) && isNumeric(closestHPt.y0)) { + tmpPoint = fillClosestPoint(closestHPt); + if(!spikePoints.hLinePoint || (spikePoints.hLinePoint.distance > tmpPoint.distance)) { + spikePoints.hLinePoint = tmpPoint; + } + } + } + } + } + } } - // nothing left: remove all labels and quit - if(hoverData.length === 0) return dragElement.unhoverRaw(gd, evt); + function selectClosestPoint(pointsData, spikedistance) { + if(!pointsData.length) return null; + var resultPoint; + var pointsDistances = pointsData.map(function(point, index) { + var xa = point.xa, + ya = point.ya, + xpx = xa.c2p(xval), + ypx = ya.c2p(yval), + dxy = function(point) { + var rad = point.kink, + dx = (point.x1 + point.x0) / 2 - xpx, + dy = (point.y1 + point.y0) / 2 - ypx; + return Math.max(Math.sqrt(dx * dx + dy * dy) - rad, 1 - 3 / rad); + }; + var distance = dxy(point); + return {distance: distance, index: index}; + }); + pointsDistances = pointsDistances + .filter(function(point) { + return point.distance <= spikedistance; + }) + .sort(function(a, b) { + return a.distance - b.distance; + }); + if(pointsDistances.length) { + resultPoint = pointsData[pointsDistances[0].index]; + } else { + resultPoint = null; + } + return resultPoint; + } + + function fillClosestPoint(point) { + if(!point) return null; + return { + xa: point.xa, + ya: point.ya, + x0: point.x0, + x1: point.x1, + y0: point.y0, + y1: point.y1, + distance: point.distance, + curveNumber: point.trace.index, + color: point.color, + pointNumber: point.index + }; + } + + var spikelineOpts = { + fullLayout: fullLayout, + container: fullLayout._hoverlayer, + outerContainer: fullLayout._paperdiv, + event: evt + }; + var oldspikepoints = gd._spikepoints, + newspikepoints = { + vLinePoint: spikePoints.vLinePoint, + hLinePoint: spikePoints.hLinePoint + }; + gd._spikepoints = newspikepoints; + + // Now if it is not restricted by spikedistance option, set the points to draw the spikelines + if(fullLayout._has('cartesian') && (spikedistance !== 0)) { + if(hoverData.length !== 0) { + var tmpHPointData = hoverData.filter(function(point) { + return point.ya.showspikes; + }); + var tmpHPoint = selectClosestPoint(tmpHPointData, spikedistance); + spikePoints.hLinePoint = fillClosestPoint(tmpHPoint); + + var tmpVPointData = hoverData.filter(function(point) { + return point.xa.showspikes; + }); + var tmpVPoint = selectClosestPoint(tmpVPointData, spikedistance); + spikePoints.vLinePoint = fillClosestPoint(tmpVPoint); + } + } + + // if hoverData is empty check for the spikes to draw and quit if there are none + if(hoverData.length === 0) { + var result = dragElement.unhoverRaw(gd, evt); + if(fullLayout._has('cartesian') && ((spikePoints.hLinePoint !== null) || (spikePoints.vLinePoint !== null))) { + if(spikesChanged(oldspikepoints)) { + createSpikelines(spikePoints, spikelineOpts); + } + } + return result; + } + + if(fullLayout._has('cartesian')) { + if(spikesChanged(oldspikepoints)) { + createSpikelines(spikePoints, spikelineOpts); + } + } hoverData.sort(function(d1, d2) { return d1.distance - d2.distance; }); @@ -422,16 +569,6 @@ function _hover(gd, evt, subplot, noHoverEvent) { gd._hoverdata = newhoverdata; - if(hoverChanged(gd, evt, oldhoverdata) && fullLayout._hasCartesian) { - var spikelineOpts = { - hovermode: hovermode, - fullLayout: fullLayout, - container: fullLayout._hoverlayer, - outerContainer: fullLayout._paperdiv - }; - createSpikelines(hoverData, spikelineOpts); - } - // if there's more than one horz bar trace, // rotate the labels so they don't overlap var rotateLabels = hovermode === 'y' && searchData.length > 1; @@ -447,7 +584,8 @@ function _hover(gd, evt, subplot, noHoverEvent) { bgColor: bgColor, container: fullLayout._hoverlayer, outerContainer: fullLayout._paperdiv, - commonLabelOpts: fullLayout.hoverlabel + commonLabelOpts: fullLayout.hoverlabel, + hoverdistance: fullLayout.hoverdistance }; var hoverLabels = createHoverText(hoverData, labelOpts, gd); @@ -511,7 +649,7 @@ function createHoverText(hoverData, opts, gd) { // show the common label, if any, on the axis // never show a common label in array mode, // even if sometimes there could be one - var showCommonLabel = c0.distance <= constants.MAXDIST && + var showCommonLabel = c0.distance <= opts.hoverdistance && (hovermode === 'x' || hovermode === 'y'); // all hover traces hoverinfo must contain the hovermode @@ -1089,75 +1227,92 @@ function cleanPoint(d, hovermode) { return d; } -function createSpikelines(hoverData, opts) { - var hovermode = opts.hovermode; +function createSpikelines(closestPoints, opts) { var container = opts.container; - var c0 = hoverData[0]; - var xa = c0.xa; - var ya = c0.ya; - var showX = xa.showspikes; - var showY = ya.showspikes; + var fullLayout = opts.fullLayout; + var evt = opts.event; + var xa, + ya; + + var showY = !!closestPoints.hLinePoint; + var showX = !!closestPoints.vLinePoint; // Remove old spikeline items container.selectAll('.spikeline').remove(); - if(hovermode !== 'closest' || !(showX || showY)) return; + if(!(showX || showY)) return; - var fullLayout = opts.fullLayout; - var xPoint = xa._offset + (c0.x0 + c0.x1) / 2; - var yPoint = ya._offset + (c0.y0 + c0.y1) / 2; var contrastColor = Color.combine(fullLayout.plot_bgcolor, fullLayout.paper_bgcolor); - var dfltDashColor = tinycolor.readability(c0.color, contrastColor) < 1.5 ? - Color.contrast(contrastColor) : c0.color; + // Horizontal line (to y-axis) if(showY) { - var yMode = ya.spikemode; - var yThickness = ya.spikethickness; - var yColor = ya.spikecolor || dfltDashColor; - var yBB = ya._boundingBox; - var xEdge = ((yBB.left + yBB.right) / 2) < xPoint ? yBB.right : yBB.left; + var hLinePoint = closestPoints.hLinePoint, + hLinePointX, + hLinePointY; + xa = hLinePoint && hLinePoint.xa; + ya = hLinePoint && hLinePoint.ya; + var ySnap = ya.spikesnap; + + if(ySnap === 'cursor') { + hLinePointX = evt.pointerX; + hLinePointY = evt.pointerY; + } else { + hLinePointX = xa._offset + (hLinePoint.x0 + hLinePoint.x1) / 2; + hLinePointY = ya._offset + (hLinePoint.y0 + hLinePoint.y1) / 2; + } + 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; if(yMode.indexOf('toaxis') !== -1 || yMode.indexOf('across') !== -1) { - var xBase = xEdge; - var xEndSpike = xPoint; + if(yMode.indexOf('toaxis') !== -1) { + xBase = xEdge; + xEndSpike = hLinePointX; + } if(yMode.indexOf('across') !== -1) { xBase = ya._counterSpan[0]; xEndSpike = ya._counterSpan[1]; } - // Background horizontal Line (to y-axis) - container.append('line') + // Foreground horizontal line (to y-axis) + container.insert('line', ':first-child') .attr({ 'x1': xBase, 'x2': xEndSpike, - 'y1': yPoint, - 'y2': yPoint, - 'stroke-width': yThickness + 2, - 'stroke': contrastColor + 'y1': hLinePointY, + 'y2': hLinePointY, + 'stroke-width': yThickness, + 'stroke': yColor, + 'stroke-dasharray': Drawing.dashStyle(ya.spikedash, yThickness) }) .classed('spikeline', true) .classed('crisp', true); - // Foreground horizontal line (to y-axis) - container.append('line') + // Background horizontal Line (to y-axis) + container.insert('line', ':first-child') .attr({ 'x1': xBase, 'x2': xEndSpike, - 'y1': yPoint, - 'y2': yPoint, - 'stroke-width': yThickness, - 'stroke': yColor, - 'stroke-dasharray': Drawing.dashStyle(ya.spikedash, yThickness) + 'y1': hLinePointY, + 'y2': hLinePointY, + 'stroke-width': yThickness + 2, + 'stroke': contrastColor }) .classed('spikeline', true) .classed('crisp', true); } // Y axis marker if(yMode.indexOf('marker') !== -1) { - container.append('circle') + container.insert('circle', ':first-child') .attr({ 'cx': xEdge + (ya.side !== 'right' ? yThickness : -yThickness), - 'cy': yPoint, + 'cy': hLinePointY, 'r': yThickness, 'fill': yColor }) @@ -1166,43 +1321,64 @@ function createSpikelines(hoverData, opts) { } if(showX) { - var xMode = xa.spikemode; - var xThickness = xa.spikethickness; - var xColor = xa.spikecolor || dfltDashColor; - var xBB = xa._boundingBox; - var yEdge = ((xBB.top + xBB.bottom) / 2) < yPoint ? xBB.bottom : xBB.top; + var vLinePoint = closestPoints.vLinePoint, + vLinePointX, + vLinePointY; + + xa = vLinePoint && vLinePoint.xa; + ya = vLinePoint && vLinePoint.ya; + var xSnap = xa.spikesnap; + + if(xSnap === 'cursor') { + vLinePointX = evt.pointerX; + vLinePointY = evt.pointerY; + } else { + vLinePointX = xa._offset + (vLinePoint.x0 + vLinePoint.x1) / 2; + vLinePointY = ya._offset + (vLinePoint.y0 + vLinePoint.y1) / 2; + } + 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; if(xMode.indexOf('toaxis') !== -1 || xMode.indexOf('across') !== -1) { - var yBase = yEdge; - var yEndSpike = yPoint; + if(xMode.indexOf('toaxis') !== -1) { + yBase = yEdge; + yEndSpike = vLinePointY; + } if(xMode.indexOf('across') !== -1) { yBase = xa._counterSpan[0]; yEndSpike = xa._counterSpan[1]; } - // Background vertical line (to x-axis) - container.append('line') + // Foreground vertical line (to x-axis) + container.insert('line', ':first-child') .attr({ - 'x1': xPoint, - 'x2': xPoint, + 'x1': vLinePointX, + 'x2': vLinePointX, 'y1': yBase, 'y2': yEndSpike, - 'stroke-width': xThickness + 2, - 'stroke': contrastColor + 'stroke-width': xThickness, + 'stroke': xColor, + 'stroke-dasharray': Drawing.dashStyle(xa.spikedash, xThickness) }) .classed('spikeline', true) .classed('crisp', true); - // Foreground vertical line (to x-axis) - container.append('line') + // Background vertical line (to x-axis) + container.insert('line', ':first-child') .attr({ - 'x1': xPoint, - 'x2': xPoint, + 'x1': vLinePointX, + 'x2': vLinePointX, 'y1': yBase, 'y2': yEndSpike, - 'stroke-width': xThickness, - 'stroke': xColor, - 'stroke-dasharray': Drawing.dashStyle(xa.spikedash, xThickness) + 'stroke-width': xThickness + 2, + 'stroke': contrastColor }) .classed('spikeline', true) .classed('crisp', true); @@ -1210,9 +1386,9 @@ function createSpikelines(hoverData, opts) { // X axis marker if(xMode.indexOf('marker') !== -1) { - container.append('circle') + container.insert('circle', ':first-child') .attr({ - 'cx': xPoint, + 'cx': vLinePointX, 'cy': yEdge - (xa.side !== 'top' ? xThickness : -xThickness), 'r': xThickness, 'fill': xColor @@ -1236,3 +1412,12 @@ function hoverChanged(gd, evt, oldhoverdata) { } return false; } + +function spikesChanged(gd, oldspikepoints) { + // don't relayout the plot because of new spikelines if spikelines points didn't change + if(!oldspikepoints) return true; + if(oldspikepoints.vLinePoint !== gd._spikepoints.vLinePoint || + oldspikepoints.hLinePoint !== gd._spikepoints.hLinePoint + ) return true; + return false; +} diff --git a/src/components/fx/layout_attributes.js b/src/components/fx/layout_attributes.js index 642a0b35853..1196cfec9f1 100644 --- a/src/components/fx/layout_attributes.js +++ b/src/components/fx/layout_attributes.js @@ -38,7 +38,28 @@ module.exports = { editType: 'modebar', description: 'Determines the mode of hover interactions.' }, - + hoverdistance: { + valType: 'integer', + min: -1, + dflt: 20, + role: 'info', + editType: 'none', + description: [ + 'Sets the default distance (in pixels) to look for data', + 'to add hover labels (-1 means no cutoff, 0 means no looking for data)' + ].join(' ') + }, + spikedistance: { + valType: 'integer', + min: -1, + dflt: 20, + role: 'info', + editType: 'none', + description: [ + 'Sets the default distance (in pixels) to look for data to draw', + 'spikelines to (-1 means no cutoff, 0 means no looking for data).' + ].join(' ') + }, hoverlabel: { bgcolor: { valType: 'color', diff --git a/src/components/fx/layout_defaults.js b/src/components/fx/layout_defaults.js index a7839edb003..b88b652d9e2 100644 --- a/src/components/fx/layout_defaults.js +++ b/src/components/fx/layout_defaults.js @@ -27,7 +27,11 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { } else hovermodeDflt = 'closest'; - coerce('hovermode', hovermodeDflt); + var hoverMode = coerce('hovermode', hovermodeDflt); + if(hoverMode) { + coerce('hoverdistance'); + coerce('spikedistance'); + } // if only mapbox or geo subplots is present on graph, // reset 'zoom' dragmode to 'pan' until 'zoom' is implemented, diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index 1d367df783b..1d3e2c643d8 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -181,7 +181,7 @@ function handleCartesian(gd, ev) { var fullLayout = gd._fullLayout; var aobj = {}; var axList = axisIds.list(gd, null, true); - var allEnabled = 'on'; + var allSpikesEnabled = 'on'; var ax, i; @@ -209,8 +209,8 @@ function handleCartesian(gd, ev) { } if(ax._showSpikeInitial !== undefined) { aobj[axName + '.showspikes'] = ax._showSpikeInitial; - if(allEnabled === 'on' && !ax._showSpikeInitial) { - allEnabled = 'off'; + if(allSpikesEnabled === 'on' && !ax._showSpikeInitial) { + allSpikesEnabled = 'off'; } } } @@ -230,24 +230,21 @@ function handleCartesian(gd, ev) { } } } - fullLayout._cartesianSpikesEnabled = allEnabled; + fullLayout._cartesianSpikesEnabled = allSpikesEnabled; } else { // if ALL traces have orientation 'h', 'hovermode': 'x' otherwise: 'y' if(astr === 'hovermode' && (val === 'x' || val === 'y')) { val = fullLayout._isHoriz ? 'y' : 'x'; button.setAttribute('data-val', val); - if(val !== 'closest') { - fullLayout._cartesianSpikesEnabled = 'off'; - } } else if(astr === 'hovermode' && val === 'closest') { for(i = 0; i < axList.length; i++) { ax = axList[i]; - if(allEnabled === 'on' && !ax.showspikes) { - allEnabled = 'off'; + if(allSpikesEnabled === 'on' && !ax.showspikes) { + allSpikesEnabled = 'off'; } } - fullLayout._cartesianSpikesEnabled = allEnabled; + fullLayout._cartesianSpikesEnabled = allSpikesEnabled; } aobj[astr] = val; @@ -551,12 +548,10 @@ modeBarButtons.toggleSpikelines = { click: function(gd) { var fullLayout = gd._fullLayout; - fullLayout._cartesianSpikesEnabled = fullLayout.hovermode === 'closest' ? - (fullLayout._cartesianSpikesEnabled === 'on' ? 'off' : 'on') : 'on'; + fullLayout._cartesianSpikesEnabled = fullLayout._cartesianSpikesEnabled === 'on' ? 'off' : 'on'; var aobj = setSpikelineVisibility(gd); - aobj.hovermode = 'closest'; Plotly.relayout(gd, aobj); } }; @@ -571,7 +566,7 @@ function setSpikelineVisibility(gd) { for(var i = 0; i < axList.length; i++) { ax = axList[i]; axName = ax._name; - aobj[axName + '.showspikes'] = fullLayout._cartesianSpikesEnabled === 'on' ? true : false; + aobj[axName + '.showspikes'] = fullLayout._cartesianSpikesEnabled === 'on' ? true : ax._showSpikeInitial; } return aobj; diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 4aeea3bd743..fe7c51ef793 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -398,7 +398,7 @@ axes.saveRangeInitial = function(gd, overwrite) { axes.saveShowSpikeInitial = function(gd, overwrite) { var axList = axes.list(gd, '', true), hasOneAxisChanged = false, - allEnabled = 'on'; + allSpikesEnabled = 'on'; for(var i = 0; i < axList.length; i++) { var ax = axList[i]; @@ -415,11 +415,11 @@ axes.saveShowSpikeInitial = function(gd, overwrite) { hasOneAxisChanged = true; } - if(allEnabled === 'on' && !ax.showspikes) { - allEnabled = 'off'; + if(allSpikesEnabled === 'on' && !ax.showspikes) { + allSpikesEnabled = 'off'; } } - gd._fullLayout._cartesianSpikesEnabled = allEnabled; + gd._fullLayout._cartesianSpikesEnabled = allSpikesEnabled; return hasOneAxisChanged; }; @@ -2048,7 +2048,7 @@ axes.doTicks = function(gd, axid, skipTitle) { top: pos, bottom: pos, left: ax._offset, - rigth: ax._offset + ax._length, + right: ax._offset + ax._length, width: ax._length, height: 0 }; diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index b4b3d05c856..225c409e249 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -387,6 +387,14 @@ module.exports = { 'plotted on' ].join(' ') }, + spikesnap: { + valType: 'enumerated', + values: ['data', 'cursor'], + dflt: 'data', + role: 'style', + editType: 'none', + description: 'Determines whether spikelines are stuck to the cursor or to the closest datapoints.' + }, tickfont: fontAttrs({ editType: 'ticks', description: 'Sets the tick font.' diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js index 2afdbe66357..149bb203d1e 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -89,6 +89,10 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { return Lib.coerce(axLayoutIn, axLayoutOut, layoutAttributes, attr, dflt); } + function coerce2(attr, dflt) { + return Lib.coerce2(axLayoutIn, axLayoutOut, layoutAttributes, attr, dflt); + } + function getCounterAxes(axLetter) { return (axLetter === 'x') ? yIds : xIds; } @@ -139,12 +143,19 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { handleAxisDefaults(axLayoutIn, axLayoutOut, coerce, defaultOptions, layoutOut); - var showSpikes = coerce('showspikes'); - if(showSpikes) { - coerce('spikecolor'); - coerce('spikethickness'); - coerce('spikedash'); - coerce('spikemode'); + var spikecolor = coerce2('spikecolor'), + spikethickness = coerce2('spikethickness'), + spikedash = coerce2('spikedash'), + spikemode = coerce2('spikemode'), + spikesnap = coerce2('spikesnap'), + showSpikes = coerce('showspikes', !!spikecolor || !!spikethickness || !!spikedash || !!spikemode || !!spikesnap); + + if(!showSpikes) { + delete axLayoutOut.spikecolor; + delete axLayoutOut.spikethickness; + delete axLayoutOut.spikedash; + delete axLayoutOut.spikemode; + delete axLayoutOut.spikesnap; } var positioningOptions = { diff --git a/src/traces/scatter/hover.js b/src/traces/scatter/hover.js index 66d5973ad18..b7348fa383c 100644 --- a/src/traces/scatter/hover.js +++ b/src/traces/scatter/hover.js @@ -79,7 +79,9 @@ module.exports = function hoverPoints(pointData, xval, yval, hovermode) { y0: yc - rad, y1: yc + rad, - yLabelVal: di.y + yLabelVal: di.y, + + kink: Math.max(minRad, di.mrc || 0) }); fillHoverText(di, trace, pointData); diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index 74238e593df..f50362f9a90 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -1723,3 +1723,213 @@ describe('ohlc hover interactions', function() { expect(d3.select('.hovertext').size()).toBe(1); }); }); + +describe('hover distance', function() { + 'use strict'; + + var mock = require('@mocks/19.json'); + + afterEach(destroyGraphDiv); + + describe('closest hovermode', function() { + var mockCopy = Lib.extendDeep({}, mock); + mockCopy.layout.hovermode = 'closest'; + + beforeEach(function(done) { + Plotly.plot(createGraphDiv(), mockCopy.data, mockCopy.layout).then(done); + }); + + it('does not render if distance to the point is larger than default (>20)', function() { + var gd = document.getElementById('graph'); + var evt = { xpx: 475, ypx: 175 }; + Fx.hover('graph', evt, 'xy'); + + expect(gd._hoverdata).toEqual(undefined); + }); + + it('render if distance to the point is less than default (<20)', function() { + var gd = document.getElementById('graph'); + var evt = { xpx: 475, ypx: 155 }; + Fx.hover('graph', evt, 'xy'); + + var hoverTrace = gd._hoverdata[0]; + + expect(hoverTrace.curveNumber).toEqual(0); + expect(hoverTrace.pointNumber).toEqual(1); + expect(hoverTrace.x).toEqual(2); + expect(hoverTrace.y).toEqual(3); + + assertHoverLabelContent({ + nums: '(2, 3)', + name: 'trace 0' + }); + }); + + it('responds to hoverdistance change', function() { + var gd = document.getElementById('graph'); + var evt = { xpx: 475, ypx: 180 }; + Plotly.relayout(gd, 'hoverdistance', 30); + + Fx.hover('graph', evt, 'xy'); + + var hoverTrace = gd._hoverdata[0]; + + expect(hoverTrace.curveNumber).toEqual(0); + expect(hoverTrace.pointNumber).toEqual(1); + expect(hoverTrace.x).toEqual(2); + expect(hoverTrace.y).toEqual(3); + + assertHoverLabelContent({ + nums: '(2, 3)', + name: 'trace 0' + }); + }); + + it('correctly responds to setting the hoverdistance to -1 by increasing ' + + 'the range of search for points to hover to Infinity', function() { + var gd = document.getElementById('graph'); + var evt = { xpx: 475, ypx: 180 }; + Plotly.relayout(gd, 'hoverdistance', -1); + + Fx.hover('graph', evt, 'xy'); + + var hoverTrace = gd._hoverdata[0]; + + expect(hoverTrace.curveNumber).toEqual(0); + expect(hoverTrace.pointNumber).toEqual(1); + expect(hoverTrace.x).toEqual(2); + expect(hoverTrace.y).toEqual(3); + + assertHoverLabelContent({ + nums: '(2, 3)', + name: 'trace 0' + }); + }); + }); + + describe('x hovermode', function() { + var mockCopy = Lib.extendDeep({}, mock); + mockCopy.layout.hovermode = 'x'; + + beforeEach(function(done) { + Plotly.plot(createGraphDiv(), mockCopy.data, mockCopy.layout).then(done); + }); + + it('does not render if distance to the point is larger than default (>20)', function() { + var gd = document.getElementById('graph'); + var evt = { xpx: 450, ypx: 155 }; + Fx.hover('graph', evt, 'xy'); + + expect(gd._hoverdata).toEqual(undefined); + }); + + it('render if distance to the point is less than default (<20)', function() { + var gd = document.getElementById('graph'); + var evt = { xpx: 475, ypx: 155 }; + Fx.hover('graph', evt, 'xy'); + + var hoverTrace = gd._hoverdata[0]; + + expect(hoverTrace.curveNumber).toEqual(0); + expect(hoverTrace.pointNumber).toEqual(1); + expect(hoverTrace.x).toEqual(2); + expect(hoverTrace.y).toEqual(3); + + assertHoverLabelContent({ + nums: '3', + axis: '2', + name: 'trace 0' + }); + }); + + it('responds to hoverdistance change from 10 to 30 (part 1)', function() { + var gd = document.getElementById('graph'); + var evt = { xpx: 450, ypx: 155 }; + Plotly.relayout(gd, 'hoverdistance', 10); + + Fx.hover('graph', evt, 'xy'); + + expect(gd._hoverdata).toEqual(undefined); + }); + + it('responds to hoverdistance change from 10 to 30 (part 2)', function() { + var gd = document.getElementById('graph'); + var evt = { xpx: 450, ypx: 155 }; + Plotly.relayout(gd, 'hoverdistance', 30); + + Fx.hover('graph', evt, 'xy'); + + var hoverTrace = gd._hoverdata[0]; + + expect(hoverTrace.curveNumber).toEqual(0); + expect(hoverTrace.pointNumber).toEqual(1); + expect(hoverTrace.x).toEqual(2); + expect(hoverTrace.y).toEqual(3); + + assertHoverLabelContent({ + nums: '3', + axis: '2', + name: 'trace 0' + }); + }); + + it('responds to hoverdistance change from default to 0 (part 1)', function() { + var gd = document.getElementById('graph'); + var evt = { xpx: 475, ypx: 155 }; + Fx.hover('graph', evt, 'xy'); + + var hoverTrace = gd._hoverdata[0]; + + expect(hoverTrace.curveNumber).toEqual(0); + expect(hoverTrace.pointNumber).toEqual(1); + expect(hoverTrace.x).toEqual(2); + expect(hoverTrace.y).toEqual(3); + + assertHoverLabelContent({ + nums: '3', + axis: '2', + name: 'trace 0' + }); + }); + + it('responds to hoverdistance change from default to 0 (part 2)', function() { + var gd = document.getElementById('graph'); + var evt = { xpx: 475, ypx: 155 }; + Plotly.relayout(gd, 'hoverdistance', 0); + + Fx.hover('graph', evt, 'xy'); + + expect(gd._hoverdata).toEqual(undefined); + }); + + it('responds to setting the hoverdistance to -1 by increasing ' + + 'the range of search for points to hover to Infinity (part 1)', function() { + var gd = document.getElementById('graph'); + var evt = { xpx: 450, ypx: 155 }; + Fx.hover('graph', evt, 'xy'); + + expect(gd._hoverdata).toEqual(undefined); + }); + + it('responds to setting the hoverdistance to -1 by increasing ' + + 'the range of search for points to hover to Infinity (part 2)', function() { + var gd = document.getElementById('graph'); + var evt = { xpx: 450, ypx: 155 }; + Plotly.relayout(gd, 'hoverdistance', -1); + + Fx.hover('graph', evt, 'xy'); + + var hoverTrace = gd._hoverdata[0]; + + expect(hoverTrace.curveNumber).toEqual(0); + expect(hoverTrace.pointNumber).toEqual(1); + expect(hoverTrace.x).toEqual(2); + expect(hoverTrace.y).toEqual(3); + + assertHoverLabelContent({ + nums: '(2, 3)', + name: 'trace 0' + }); + }); + }); +}); diff --git a/test/jasmine/tests/hover_spikeline_test.js b/test/jasmine/tests/hover_spikeline_test.js index aca3c9f29d2..c9655f8269a 100644 --- a/test/jasmine/tests/hover_spikeline_test.js +++ b/test/jasmine/tests/hover_spikeline_test.js @@ -16,15 +16,15 @@ describe('spikeline', function() { describe('hover', function() { var gd; - function makeMock() { + function makeMock(spikemode, hovermode) { var _mock = Lib.extendDeep({}, require('@mocks/19.json')); _mock.layout.xaxis.showspikes = true; - _mock.layout.xaxis.spikemode = 'toaxis'; + _mock.layout.xaxis.spikemode = spikemode; _mock.layout.yaxis.showspikes = true; - _mock.layout.yaxis.spikemode = 'toaxis+marker'; + _mock.layout.yaxis.spikemode = spikemode + '+marker'; _mock.layout.xaxis2.showspikes = true; - _mock.layout.xaxis2.spikemode = 'toaxis'; - _mock.layout.hovermode = 'closest'; + _mock.layout.xaxis2.spikemode = spikemode; + _mock.layout.hovermode = hovermode; return _mock; } @@ -33,6 +33,14 @@ describe('spikeline', function() { Lib.clearThrottle(); } + function _set_hovermode(hovermode) { + return Plotly.relayout(gd, 'hovermode', hovermode); + } + + function _set_spikedistance(spikedistance) { + return Plotly.relayout(gd, 'spikedistance', spikedistance); + } + function _assert(lineExpect, circleExpect) { var TOL = 5; var lines = d3.selectAll('line.spikeline'); @@ -58,14 +66,14 @@ describe('spikeline', function() { }); } - it('draws lines and markers on enabled axes', function(done) { + it('draws lines and markers on enabled axes in the closest hovermode', function(done) { gd = createGraphDiv(); - var _mock = makeMock(); + var _mock = makeMock('toaxis', 'closest'); Plotly.plot(gd, _mock).then(function() { _hover({xval: 2, yval: 3}, 'xy'); _assert( - [[80, 250, 557, 250], [80, 250, 557, 250], [557, 401, 557, 250], [557, 401, 557, 250]], + [[557, 401, 557, 250], [557, 401, 557, 250], [80, 250, 557, 250], [80, 250, 557, 250]], [[83, 250]] ); }) @@ -82,7 +90,7 @@ describe('spikeline', function() { it('draws lines and markers on enabled axes w/o tick labels', function(done) { gd = createGraphDiv(); - var _mock = makeMock(); + var _mock = makeMock('toaxis', 'closest'); _mock.layout.xaxis.showticklabels = false; _mock.layout.yaxis.showticklabels = false; @@ -90,7 +98,7 @@ describe('spikeline', function() { Plotly.plot(gd, _mock).then(function() { _hover({xval: 2, yval: 3}, 'xy'); _assert( - [[80, 250, 557, 250], [80, 250, 557, 250], [557, 401, 557, 250], [557, 401, 557, 250]], + [[557, 401, 557, 250], [557, 401, 557, 250], [80, 250, 557, 250], [80, 250, 557, 250]], [[83, 250]] ); }) @@ -104,5 +112,215 @@ describe('spikeline', function() { .catch(fail) .then(done); }); + + it('draws lines and markers on enabled axes in the x hovermode', function(done) { + gd = createGraphDiv(); + var _mock = makeMock('across', 'x'); + + Plotly.plot(gd, _mock).then(function() { + _hover({xval: 2, yval: 3}, 'xy'); + _assert( + [[557, 100, 557, 401], [557, 100, 557, 401], [80, 250, 1036, 250], [80, 250, 1036, 250]], + [[83, 250]] + ); + }) + .then(function() { + _hover({xval: 30, yval: 40}, 'x2y2'); + _assert( + [[820, 116, 820, 220], [820, 116, 820, 220]], + [] + ); + }) + .catch(fail) + .then(done); + }); + + it('draws lines and markers on enabled axes in the spikesnap "cursor" mode', function(done) { + gd = createGraphDiv(); + var _mock = makeMock('toaxis', 'x'); + + _mock.layout.xaxis.spikesnap = 'cursor'; + _mock.layout.yaxis.spikesnap = 'cursor'; + _mock.layout.xaxis2.spikesnap = 'cursor'; + + Plotly.plot(gd, _mock) + .then(function() { + _set_spikedistance(200); + }) + .then(function() { + _hover({xpx: 120, ypx: 180}, 'xy'); + _assert( + [[200, 401, 200, 280], [200, 401, 200, 280], [80, 280, 200, 280], [80, 280, 200, 280]], + [[83, 280]] + ); + }) + .then(function() { + _hover({xpx: 31, ypx: 41}, 'x2y2'); + _assert( + [[682, 220, 682, 156], [682, 220, 682, 156]], + [] + ); + }) + .catch(fail) + .then(done); + }); + + it('doesn\'t switch between toaxis and across spikemodes on switching the hovermodes', function(done) { + gd = createGraphDiv(); + var _mock = makeMock('toaxis', 'closest'); + + Plotly.plot(gd, _mock).then(function() { + _hover({xval: 2, yval: 3}, 'xy'); + _assert( + [[557, 401, 557, 250], [557, 401, 557, 250], [80, 250, 557, 250], [80, 250, 557, 250]], + [[83, 250]] + ); + }) + .then(function() { + _hover({xval: 30, yval: 40}, 'x2y2'); + _assert( + [[820, 220, 820, 167], [820, 220, 820, 167]], + [] + ); + }) + .then(function() { + _set_hovermode('x'); + }) + .then(function() { + _hover({xval: 2, yval: 3}, 'xy'); + _assert( + [[557, 401, 557, 250], [557, 401, 557, 250], [80, 250, 557, 250], [80, 250, 557, 250]], + [[83, 250]] + ); + }) + .then(function() { + _hover({xval: 30, yval: 40}, 'x2y2'); + _assert( + [[820, 220, 820, 167], [820, 220, 820, 167]], + [] + ); + }) + .catch(fail) + .then(done); + }); + + it('increase the range of search for points to draw the spikelines on spikedistance change', function(done) { + gd = createGraphDiv(); + var _mock = makeMock('toaxis', 'closest'); + + Plotly.plot(gd, _mock).then(function() { + _hover({xval: 1.6, yval: 2.6}, 'xy'); + _assert( + [], + [] + ); + }) + .then(function() { + _hover({xval: 26, yval: 36}, 'x2y2'); + _assert( + [], + [] + ); + }) + .then(function() { + _set_spikedistance(200); + }) + .then(function() { + _hover({xval: 1.6, yval: 2.6}, 'xy'); + _assert( + [[557, 401, 557, 250], [557, 401, 557, 250], [80, 250, 557, 250], [80, 250, 557, 250]], + [[83, 250]] + ); + }) + .then(function() { + _hover({xval: 26, yval: 36}, 'x2y2'); + _assert( + [[820, 220, 820, 167], [820, 220, 820, 167]], + [] + ); + }) + .catch(fail) + .then(done); + }); + + it('correctly responds to setting the spikedistance to -1 by increasing ' + + 'the range of search for points to draw the spikelines to Infinity', function(done) { + gd = createGraphDiv(); + var _mock = makeMock('toaxis', 'closest'); + + Plotly.plot(gd, _mock).then(function() { + _hover({xval: 1.6, yval: 2.6}, 'xy'); + _assert( + [], + [] + ); + }) + .then(function() { + _hover({xval: 26, yval: 36}, 'x2y2'); + _assert( + [], + [] + ); + }) + .then(function() { + _set_spikedistance(-1); + }) + .then(function() { + _hover({xval: 1.6, yval: 2.6}, 'xy'); + _assert( + [[557, 401, 557, 250], [557, 401, 557, 250], [80, 250, 557, 250], [80, 250, 557, 250]], + [[83, 250]] + ); + }) + .then(function() { + _hover({xval: 26, yval: 36}, 'x2y2'); + _assert( + [[820, 220, 820, 167], [820, 220, 820, 167]], + [] + ); + }) + .catch(fail) + .then(done); + }); + + it('correctly responds to setting the spikedistance to 0 by disabling ' + + 'the search for points to draw the spikelines', function(done) { + gd = createGraphDiv(); + var _mock = makeMock('toaxis', 'closest'); + + Plotly.plot(gd, _mock).then(function() { + _hover({xval: 2, yval: 3}, 'xy'); + _assert( + [[557, 401, 557, 250], [557, 401, 557, 250], [80, 250, 557, 250], [80, 250, 557, 250]], + [[83, 250]] + ); + }) + .then(function() { + _hover({xval: 30, yval: 40}, 'x2y2'); + _assert( + [[820, 220, 820, 167], [820, 220, 820, 167]], + [] + ); + }) + .then(function() { + _set_spikedistance(0); + }) + .then(function() { + _hover({xval: 2, yval: 3}, 'xy'); + _assert( + [], + [] + ); + }) + .then(function() { + _hover({xval: 30, yval: 40}, 'x2y2'); + _assert( + [], + [] + ); + }) + .catch(fail) + .then(done); + }); }); }); diff --git a/test/jasmine/tests/modebar_test.js b/test/jasmine/tests/modebar_test.js index f17dbbd9f8e..e798ff369c4 100644 --- a/test/jasmine/tests/modebar_test.js +++ b/test/jasmine/tests/modebar_test.js @@ -861,13 +861,13 @@ describe('ModeBar', function() { }); describe('button toggleSpikelines', function() { - it('should update layout hovermode', function() { + it('should not change layout hovermode', function() { expect(gd._fullLayout.hovermode).toBe('x'); assertActive(hovermodeButtons, buttonCompare); buttonToggle.click(); - expect(gd._fullLayout.hovermode).toBe('closest'); - assertActive(hovermodeButtons, buttonClosest); + expect(gd._fullLayout.hovermode).toBe('x'); + assertActive(hovermodeButtons, buttonCompare); }); it('should makes spikelines visible', function() { buttonToggle.click(); @@ -876,22 +876,25 @@ describe('ModeBar', function() { buttonToggle.click(); expect(gd._fullLayout._cartesianSpikesEnabled).toBe('off'); }); - it('should become disabled when hovermode is switched off closest', function() { + it('should not become disabled when hovermode is switched off closest', function() { buttonToggle.click(); expect(gd._fullLayout._cartesianSpikesEnabled).toBe('on'); buttonCompare.click(); - expect(gd._fullLayout._cartesianSpikesEnabled).toBe('off'); + expect(gd._fullLayout._cartesianSpikesEnabled).toBe('on'); }); - it('should be re-enabled when hovermode is set to closest if it was previously on', function() { + it('should keep the state on changing the hovermode', function() { buttonToggle.click(); expect(gd._fullLayout._cartesianSpikesEnabled).toBe('on'); buttonCompare.click(); + expect(gd._fullLayout._cartesianSpikesEnabled).toBe('on'); + + buttonToggle.click(); expect(gd._fullLayout._cartesianSpikesEnabled).toBe('off'); buttonClosest.click(); - expect(gd._fullLayout._cartesianSpikesEnabled).toBe('on'); + expect(gd._fullLayout._cartesianSpikesEnabled).toBe('off'); }); }); });