diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 20bffd17d6c..e3b2a07a757 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -45,6 +45,8 @@ var YSHIFTY = Math.sin(YA_RADIANS); var HOVERARROWSIZE = constants.HOVERARROWSIZE; var HOVERTEXTPAD = constants.HOVERTEXTPAD; +var XY = {x: 1, y: 1}; + // fx.hover: highlight data on hover // evt can be a mousemove event, or an object with data about what points // to hover on @@ -365,176 +367,181 @@ function _hover(gd, evt, subplot, noHoverEvent) { // find the closest point in each trace // this is minimum dx and/or dy, depending on mode // and the pixel position for the label (labelXpx, labelYpx) - for(curvenum = 0; curvenum < searchData.length; curvenum++) { - cd = searchData[curvenum]; - - // filter out invisible or broken data - if(!cd || !cd[0] || !cd[0].trace) continue; - - trace = cd[0].trace; + function findHoverPoints(customXVal, customYVal) { + for(curvenum = 0; curvenum < searchData.length; curvenum++) { + cd = searchData[curvenum]; - if(trace.visible !== true || trace._length === 0) continue; + // filter out invisible or broken data + if(!cd || !cd[0] || !cd[0].trace) continue; - // Explicitly bail out for these two. I don't know how to otherwise prevent - // the rest of this function from running and failing - if(['carpet', 'contourcarpet'].indexOf(trace._module.name) !== -1) continue; + trace = cd[0].trace; - if(trace.type === 'splom') { - // splom traces do not generate overlay subplots, - // it is safe to assume here splom traces correspond to the 0th subplot - subploti = 0; - subplotId = subplots[subploti]; - } else { - subplotId = helpers.getSubplot(trace); - subploti = subplots.indexOf(subplotId); - } + if(trace.visible !== true || trace._length === 0) continue; - // within one trace mode can sometimes be overridden - mode = hovermode; - if(['x unified', 'y unified'].indexOf(mode) !== -1) { - mode = mode.charAt(0); - } + // Explicitly bail out for these two. I don't know how to otherwise prevent + // the rest of this function from running and failing + if(['carpet', 'contourcarpet'].indexOf(trace._module.name) !== -1) continue; - // container for new point, also used to pass info into module.hoverPoints - pointData = { - // trace properties - cd: cd, - trace: trace, - xa: xaArray[subploti], - ya: yaArray[subploti], - - // max distances for hover and spikes - for points that want to show but do not - // want to override other points, set distance/spikeDistance equal to max*Distance - // and it will not get filtered out but it will be guaranteed to have a greater - // distance than any point that calculated a real distance. - maxHoverDistance: hoverdistance, - maxSpikeDistance: spikedistance, - - // point properties - override all of these - index: false, // point index in trace - only used by plotly.js hoverdata consumers - distance: Math.min(distance, hoverdistance), // pixel distance or pseudo-distance - - // distance/pseudo-distance for spikes. This distance should always be calculated - // as if in "closest" mode, and should only be set if this point should - // generate a spike. - spikeDistance: Infinity, - - // in some cases the spikes have different positioning from the hover label - // they don't need x0/x1, just one position - xSpike: undefined, - ySpike: undefined, - - // where and how to display the hover label - color: Color.defaultLine, // trace color - name: trace.name, - x0: undefined, - x1: undefined, - y0: undefined, - y1: undefined, - xLabelVal: undefined, - yLabelVal: undefined, - zLabelVal: undefined, - text: undefined - }; + if(trace.type === 'splom') { + // splom traces do not generate overlay subplots, + // it is safe to assume here splom traces correspond to the 0th subplot + subploti = 0; + subplotId = subplots[subploti]; + } else { + subplotId = helpers.getSubplot(trace); + subploti = subplots.indexOf(subplotId); + } - // add ref to subplot object (non-cartesian case) - if(fullLayout[subplotId]) { - pointData.subplot = fullLayout[subplotId]._subplot; - } - // add ref to splom scene - if(fullLayout._splomScenes && fullLayout._splomScenes[trace.uid]) { - pointData.scene = fullLayout._splomScenes[trace.uid]; - } + // within one trace mode can sometimes be overridden + mode = hovermode; + if(['x unified', 'y unified'].indexOf(mode) !== -1) { + mode = mode.charAt(0); + } - closedataPreviousLength = hoverData.length; + // container for new point, also used to pass info into module.hoverPoints + pointData = { + // trace properties + cd: cd, + trace: trace, + xa: xaArray[subploti], + ya: yaArray[subploti], + + // max distances for hover and spikes - for points that want to show but do not + // want to override other points, set distance/spikeDistance equal to max*Distance + // and it will not get filtered out but it will be guaranteed to have a greater + // distance than any point that calculated a real distance. + maxHoverDistance: hoverdistance, + maxSpikeDistance: spikedistance, + + // point properties - override all of these + index: false, // point index in trace - only used by plotly.js hoverdata consumers + distance: Math.min(distance, hoverdistance), // pixel distance or pseudo-distance + + // distance/pseudo-distance for spikes. This distance should always be calculated + // as if in "closest" mode, and should only be set if this point should + // generate a spike. + spikeDistance: Infinity, + + // in some cases the spikes have different positioning from the hover label + // they don't need x0/x1, just one position + xSpike: undefined, + ySpike: undefined, + + // where and how to display the hover label + color: Color.defaultLine, // trace color + name: trace.name, + x0: undefined, + x1: undefined, + y0: undefined, + y1: undefined, + xLabelVal: undefined, + yLabelVal: undefined, + zLabelVal: undefined, + text: undefined + }; + + // add ref to subplot object (non-cartesian case) + if(fullLayout[subplotId]) { + pointData.subplot = fullLayout[subplotId]._subplot; + } + // add ref to splom scene + if(fullLayout._splomScenes && fullLayout._splomScenes[trace.uid]) { + pointData.scene = fullLayout._splomScenes[trace.uid]; + } - // for a highlighting array, figure out what - // we're searching for with this element - if(mode === 'array') { - var selection = evt[curvenum]; - if('pointNumber' in selection) { - pointData.index = selection.pointNumber; - mode = 'closest'; - } else { - mode = ''; - if('xval' in selection) { - xval = selection.xval; - mode = 'x'; - } - if('yval' in selection) { - yval = selection.yval; - mode = mode ? 'closest' : 'y'; + closedataPreviousLength = hoverData.length; + + // for a highlighting array, figure out what + // we're searching for with this element + if(mode === 'array') { + var selection = evt[curvenum]; + if('pointNumber' in selection) { + pointData.index = selection.pointNumber; + mode = 'closest'; + } else { + mode = ''; + if('xval' in selection) { + xval = selection.xval; + mode = 'x'; + } + if('yval' in selection) { + yval = selection.yval; + mode = mode ? 'closest' : 'y'; + } } + } else if(customXVal !== undefined && customYVal !== undefined) { + xval = customXVal; + yval = customYVal; + } else { + xval = xvalArray[subploti]; + yval = yvalArray[subploti]; } - } else { - xval = xvalArray[subploti]; - yval = yvalArray[subploti]; - } - // 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)); + // 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 - // and don't look any farther than this latest point (or points, some - // traces like box & violin make multiple hover labels at once) - if(hovermode === 'closest' && hoverData.length > closedataPreviousLength) { - hoverData.splice(0, closedataPreviousLength); - distance = hoverData[0].distance; - } + // in closest mode, remove any existing (farther) points + // and don't look any farther than this latest point (or points, some + // traces like box & violin make multiple hover labels at once) + if(hovermode === 'closest' && hoverData.length > closedataPreviousLength) { + 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(hasCartesian && (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) { - closestPoints = closestPoints.filter(function(point) { - // some hover points, like scatter fills, do not allow spikes, - // so will generate a hover point but without a valid spikeDistance - return point.spikeDistance <= spikedistance; - }); - } - if(closestPoints && closestPoints.length) { - 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 = fillSpikePoint(closestVPt); - if(!spikePoints.vLinePoint || (spikePoints.vLinePoint.spikeDistance > tmpPoint.spikeDistance)) { - spikePoints.vLinePoint = tmpPoint; + // Now if there is range to look in, find the points to draw the spikelines + // Do it only if there is no hoverData + if(hasCartesian && (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) { + closestPoints = closestPoints.filter(function(point) { + // some hover points, like scatter fills, do not allow spikes, + // so will generate a hover point but without a valid spikeDistance + return point.spikeDistance <= spikedistance; + }); + } + if(closestPoints && closestPoints.length) { + 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 = fillSpikePoint(closestVPt); + if(!spikePoints.vLinePoint || (spikePoints.vLinePoint.spikeDistance > tmpPoint.spikeDistance)) { + 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 = fillSpikePoint(closestHPt); - if(!spikePoints.hLinePoint || (spikePoints.hLinePoint.spikeDistance > tmpPoint.spikeDistance)) { - spikePoints.hLinePoint = 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 = fillSpikePoint(closestHPt); + if(!spikePoints.hLinePoint || (spikePoints.hLinePoint.spikeDistance > tmpPoint.spikeDistance)) { + spikePoints.hLinePoint = tmpPoint; + } } } } @@ -543,6 +550,8 @@ function _hover(gd, evt, subplot, noHoverEvent) { } } + findHoverPoints(); + function selectClosestPoint(pointsData, spikedistance) { var resultPoint = null; var minDistance = Infinity; @@ -621,6 +630,45 @@ function _hover(gd, evt, subplot, noHoverEvent) { hoverData.sort(function(d1, d2) { return d1.distance - d2.distance; }); + // If in compare mode, select every point at position + if(hoverData[0].length !== 0 && + XY[mode] && + hoverData[0].trace.type !== 'splom' // TODO: add support for splom + ) { + var hd = hoverData[0]; + var cd0 = hd.cd[hd.index]; + var isGrouped = (fullLayout.boxmode === 'group' || fullLayout.violinmode === 'group'); + + var xVal = hd.xVal; + var ax = hd.xa; + if(ax.type === 'category') xVal = ax._categoriesMap[xVal]; + if(ax.type === 'date') xVal = ax.d2c(xVal); + if(cd0 && cd0.t && cd0.t.posLetter === ax._id && isGrouped) { + xVal += cd0.t.dPos; + } + + var yVal = hd.yVal; + ax = hd.ya; + if(ax.type === 'category') yVal = ax._categoriesMap[yVal]; + if(ax.type === 'date') yVal = ax.d2c(yVal); + if(cd0 && cd0.t && cd0.t.posLetter === ax._id && isGrouped) { + yVal += cd0.t.dPos; + } + + findHoverPoints(xVal, yVal); + + // Remove duplicated hoverData points + // note that d3 also filters identical points in the rendering steps + var repeated = {}; + hoverData = hoverData.filter(function(hd) { + var key = hoverDataKey(hd); + if(!repeated[key]) { + repeated[key] = true; + return repeated[key]; + } + }); + } + // lastly, emit custom hover/unhover events var oldhoverdata = gd._hoverdata; var newhoverdata = []; @@ -699,6 +747,10 @@ function _hover(gd, evt, subplot, noHoverEvent) { }); } +function hoverDataKey(d) { + return [d.trace.index, d.index, d.x0, d.y0, d.name, d.attr, d.xa, d.ya || ''].join(','); +} + var EXTRA_STRING_REGEX = /([\s\S]*)<\/extra>/; function createHoverText(hoverData, opts, gd) { @@ -1028,7 +1080,7 @@ function createHoverText(hoverData, opts, gd) { .data(hoverData, function(d) { // N.B. when multiple items have the same result key-function value, // only the first of those items in hoverData gets rendered - return [d.trace.index, d.index, d.x0, d.y0, d.name, d.attr, d.xa, d.ya || ''].join(','); + return hoverDataKey(d); }); hoverLabels.enter().append('g') .classed('hovertext', true) diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index 2d7a1736605..95d3b602cd5 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -996,8 +996,14 @@ describe('hover info', function() { _hover(gd, 250, 270); assertHoverLabelContent({ - nums: 'x: 1\ny: 1\nz: 5.56', - name: 'one' + nums: [ + 'x: 1\ny: 1\nz: 5.56', + 'x: 1\ny: 1\nz: 0' + ], + name: [ + 'one', + 'two' + ] }); }) .then(function() { @@ -1012,8 +1018,8 @@ describe('hover info', function() { _hover(gd, 250, 270); assertHoverLabelContent({ - nums: 'f(1.0, 1.0)=5.56', - name: '' + nums: ['f(1.0, 1.0)=5.56', 'f(1.0, 1.0)=0'], + name: ['', ''] }); }) .catch(failTest) @@ -3629,6 +3635,84 @@ describe('hover distance', function() { }); }); }); + + describe('compare', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('selects all points at the same position on a linear axis', function(done) { + var x = [0, 1, 2]; + var mock = { + data: [{type: 'bar', x: x, y: [4, 5, 6]}, {x: x, y: [5, 6, 7]}], + layout: {width: 400, height: 400, xaxis: {type: 'linear'}} + }; + + Plotly.newPlot(gd, mock) + .then(function(gd) { + Fx.hover(gd, {xpx: 65}); + + expect(gd._hoverdata.length).toEqual(2); + }) + .catch(failTest) + .then(done); + }); + + it('selects all points at the same position on a log axis', function(done) { + var x = [0, 1, 2]; + var mock = { + data: [{type: 'bar', x: x, y: [4, 5, 6]}, {x: x, y: [5, 6, 7]}], + layout: {width: 400, height: 400, xaxis: {type: 'log'}} + }; + + Plotly.newPlot(gd, mock) + .then(function(gd) { + Fx.hover(gd, {xpx: 65}); + + expect(gd._hoverdata.length).toEqual(2); + }) + .catch(failTest) + .then(done); + }); + + it('selects all points at the same position on a category axis', function(done) { + var x = ['a', 'b', 'c']; + var mock = { + data: [{type: 'bar', x: x, y: [4, 5, 6]}, {x: x, y: [5, 6, 7]}], + layout: {width: 400, height: 400, xaxis: {type: 'category'}} + }; + + Plotly.newPlot(gd, mock) + .then(function(gd) { + Fx.hover(gd, {xpx: 65}); + + expect(gd._hoverdata.length).toEqual(2); + }) + .catch(failTest) + .then(done); + }); + + it('selects all points at the same position on a date axis', function(done) { + var x = ['2018', '2019', '2020']; + var mock = { + data: [{type: 'bar', x: x, y: [4, 5, 6]}, {x: x, y: [5, 6, 7]}], + layout: {width: 400, height: 400, xaxis: {type: 'date'}} + }; + + Plotly.newPlot(gd, mock) + .then(function(gd) { + Fx.hover(gd, {xpx: 65}); + + expect(gd._hoverdata.length).toEqual(2); + }) + .catch(failTest) + .then(done); + }); + }); }); describe('hover label rotation:', function() {