diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 37d80fbc126..ce06b829326 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -232,7 +232,13 @@ function _hover(gd, evt, subplot, noHoverEvent) { xval, yval, pointData, - closedataPreviousLength; + closedataPreviousLength, + + // crosslinePoints: the set of candidate points we've found to draw crosslines to + crosslinePoints = { + hLinePoint: null, + vLinePoint: null + }; // Figure out what we're hovering on: // mouse location or user-supplied data @@ -379,6 +385,19 @@ function _hover(gd, evt, subplot, noHoverEvent) { yval = yvalArray[subploti]; } + var showSpikes = fullLayout.xaxis && fullLayout.xaxis.showspikes && fullLayout.yaxis && fullLayout.yaxis.showspikes; + var showCrosslines = fullLayout.xaxis && fullLayout.xaxis.showcrossline || fullLayout.yaxis && fullLayout.yaxis.showcrossline; + + // Find the points for the crosslines first to avoid overwriting the hoverLabels data. + if(fullLayout._has('cartesian') && showCrosslines && !(showSpikes && hovermode === 'closest')) { + if(fullLayout.yaxis.showcrossline) { + crosslinePoints.hLinePoint = findCrosslinePoint(pointData, xval, yval, 'y', crosslinePoints.hLinePoint); + } + if(fullLayout.xaxis.showcrossline) { + crosslinePoints.vLinePoint = findCrosslinePoint(pointData, xval, yval, 'x', crosslinePoints.vLinePoint); + } + } + // Now find the points. if(trace._module && trace._module.hoverPoints) { var newPoints = trace._module.hoverPoints(pointData, xval, yval, mode, fullLayout._hoverlayer); @@ -404,8 +423,49 @@ function _hover(gd, evt, subplot, noHoverEvent) { } } - // nothing left: remove all labels and quit - if(hoverData.length === 0) return dragElement.unhoverRaw(gd, evt); + function findCrosslinePoint(pointData, xval, yval, mode, endPoint) { + var tmpDistance = pointData.distance; + var tmpIndex = pointData.index; + var resultPoint = endPoint; + pointData.distance = Infinity; + pointData.index = false; + var closestPoints = trace._module.hoverPoints(pointData, xval, yval, mode); + if(closestPoints) { + var closestPt = closestPoints[0]; + if(isNumeric(closestPt.x0) && isNumeric(closestPt.y0)) { + var tmpPoint = { + xa: closestPt.xa, + ya: closestPt.ya, + x0: closestPt.x0, + x1: closestPt.x1, + y0: closestPt.y0, + y1: closestPt.y1, + distance: closestPt.distance, + curveNumber: closestPt.trace.index, + pointNumber: closestPt.index + }; + if(!resultPoint || (resultPoint.distance > tmpPoint.distance)) { + resultPoint = tmpPoint; + } + } + } + pointData.index = tmpIndex; + pointData.distance = tmpDistance; + return resultPoint; + } + + // if hoverData is empty check for the crosslines to draw and quit if there are none + if(hoverData.length === 0) { + var result = dragElement.unhoverRaw(gd, evt); + if(fullLayout._has('cartesian') && ((crosslinePoints.hLinePoint !== null) || (crosslinePoints.vLinePoint !== null))) { + createCrosslines(crosslinePoints, fullLayout); + } + return result; + } + + if(fullLayout._has('cartesian')) { + createCrosslines(crosslinePoints, fullLayout); + } hoverData.sort(function(d1, d2) { return d1.distance - d2.distance; }); @@ -1108,6 +1168,77 @@ function cleanPoint(d, hovermode) { return d; } +function createCrosslines(closestPoints, fullLayout) { + var showXSpikeline = fullLayout.xaxis && fullLayout.xaxis.showspikes; + var showYSpikeline = fullLayout.yaxis && fullLayout.yaxis.showspikes; + var showH = fullLayout.yaxis && fullLayout.yaxis.showcrossline; + var showV = fullLayout.xaxis && fullLayout.xaxis.showcrossline; + var container = fullLayout._hoverlayer; + var hovermode = fullLayout.hovermode; + if(!(showV || showH) || (showXSpikeline && showYSpikeline && hovermode === 'closest')) return; + var hLinePoint, + vLinePoint, + xa, + ya, + hLinePointY, + vLinePointX; + + // Remove old crossline items + container.selectAll('.crossline').remove(); + + var contrastColor = Color.combine(fullLayout.plot_bgcolor, fullLayout.paper_bgcolor); + var dfltCrosslineColor = Color.contrast(contrastColor); + + // do not draw a crossline if there is a spikeline + if(showV && !(showXSpikeline && hovermode === 'closest')) { + vLinePoint = closestPoints.vLinePoint; + xa = vLinePoint.xa; + vLinePointX = xa._offset + (vLinePoint.x0 + vLinePoint.x1) / 2; + + var xThickness = xa.crosslinethickness; + var xDash = xa.crosslinedash; + var xColor = xa.crosslinecolor || dfltCrosslineColor; + + // Foreground vertical line (to x-axis) + container.insert('line', ':first-child') + .attr({ + 'x1': vLinePointX, + 'x2': vLinePointX, + 'y1': xa._counterSpan[0], + 'y2': xa._counterSpan[1], + 'stroke-width': xThickness, + 'stroke': xColor, + 'stroke-dasharray': Drawing.dashStyle(xDash, xThickness) + }) + .classed('crossline', true) + .classed('crisp', true); + } + + if(showH && !(showYSpikeline && hovermode === 'closest')) { + hLinePoint = closestPoints.hLinePoint; + ya = hLinePoint.ya; + hLinePointY = ya._offset + (hLinePoint.y0 + hLinePoint.y1) / 2; + + var yThickness = ya.crosslinethickness; + var yDash = ya.crosslinedash; + var yColor = ya.crosslinecolor || dfltCrosslineColor; + + // Foreground horizontal line (to y-axis) + container.insert('line', ':first-child') + .attr({ + 'x1': ya._counterSpan[0], + 'x2': ya._counterSpan[1], + 'y1': hLinePointY, + 'y2': hLinePointY, + 'stroke-width': yThickness, + 'stroke': yColor, + 'stroke-dasharray': Drawing.dashStyle(yDash, yThickness) + }) + .classed('crossline', true) + .classed('crisp', true); + } +} + function createSpikelines(hoverData, opts) { var hovermode = opts.hovermode; var container = opts.container; diff --git a/src/components/fx/index.js b/src/components/fx/index.js index dd69c0315e3..db19b820499 100644 --- a/src/components/fx/index.js +++ b/src/components/fx/index.js @@ -59,6 +59,7 @@ function loneUnhover(containerOrSelection) { selection.selectAll('g.hovertext').remove(); selection.selectAll('.spikeline').remove(); + selection.selectAll('.crossline').remove(); } // helpers for traces that use Fx.loneHover diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index e4a1e83d9bb..ca734401ef2 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -166,6 +166,9 @@ Plotly.plot = function(gd, data, layout, config) { // save initial show spikes once per graph if(graphWasEmpty) Plotly.Axes.saveShowSpikeInitial(gd); + // save initial show crosslines once per graph + if(graphWasEmpty) Plotly.Axes.saveShowCrosslineInitial(gd); + // prepare the data and find the autorange // generate calcdata, if we need to diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 3b24d00cc41..da6bf1a8bdd 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -426,6 +426,30 @@ axes.saveShowSpikeInitial = function(gd, overwrite) { return hasOneAxisChanged; }; +// save a copy of the initial crossline visibility +axes.saveShowCrosslineInitial = function(gd, overwrite) { + var axList = axes.list(gd, '', true), + hasOneAxisChanged = false; + + for(var i = 0; i < axList.length; i++) { + var ax = axList[i]; + + var isNew = (ax._showCrosslineInitial === undefined); + var hasChanged = ( + isNew || !( + ax.showcrossline === ax._showcrossline + ) + ); + + if((isNew) || (overwrite && hasChanged)) { + ax._showCrosslineInitial = ax.showcrossline; + hasOneAxisChanged = true; + } + + } + return hasOneAxisChanged; +}; + // axes.expand: if autoranging, include new data in the outer limits // for this axis // data is an array of numbers (ie already run through ax.d2c) @@ -2121,7 +2145,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 aae0ac26b43..796a8ecd3ee 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -387,6 +387,28 @@ module.exports = { 'plotted on' ].join(' ') }, + showcrossline: { + valType: 'boolean', + dflt: false, + role: 'style', + editType: 'none', + description: 'Determines whether or not crossline are drawn for this axis.' + }, + crosslinecolor: { + valType: 'color', + dflt: null, + role: 'style', + editType: 'none', + description: 'Sets the crossline color. If undefined, will use the contrast to background color' + }, + crosslinethickness: { + valType: 'number', + dflt: 2, + role: 'style', + editType: 'none', + description: 'Sets the width (in px) of the zero line.' + }, + crosslinedash: extendFlat({}, dash, {dflt: 'solid', editType: 'none'}), 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 6765fdcdec7..7061388ec3a 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -191,6 +191,13 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { handleAxisDefaults(axLayoutIn, axLayoutOut, coerce, defaultOptions, layoutOut); + var showCrossline = coerce('showcrossline'); + if(showCrossline) { + coerce('crosslinecolor'); + coerce('crosslinethickness'); + coerce('crosslinedash'); + } + var showSpikes = coerce('showspikes'); if(showSpikes) { coerce('spikecolor'); diff --git a/test/jasmine/tests/hover_crossline_test.js b/test/jasmine/tests/hover_crossline_test.js new file mode 100644 index 00000000000..6e054f3d61c --- /dev/null +++ b/test/jasmine/tests/hover_crossline_test.js @@ -0,0 +1,179 @@ +var d3 = require('d3'); + +var Plotly = require('@lib/index'); +var Fx = require('@src/components/fx'); +var Lib = require('@src/lib'); + +var fail = require('../assets/fail_test'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); + +describe('crossline', function() { + 'use strict'; + + afterEach(destroyGraphDiv); + + describe('hover', function() { + var gd; + + function makeMock() { + var _mock = Lib.extendDeep({}, require('@mocks/19.json')); + _mock.layout.xaxis.showcrossline = true; + _mock.layout.yaxis.showcrossline = true; + _mock.layout.xaxis2.showcrossline = true; + _mock.layout.hovermode = 'closest'; + return _mock; + } + + function _hover(evt, subplot) { + Fx.hover(gd, evt, subplot); + Lib.clearThrottle(); + } + + function _assert(lineExpect) { + var TOL = 5; + var lines = d3.selectAll('line.crossline'); + + expect(lines.size()).toBe(lineExpect.length, '# of line nodes'); + + lines.each(function(_, i) { + var sel = d3.select(this); + ['x1', 'y1', 'x2', 'y2'].forEach(function(d, j) { + expect(sel.attr(d)) + .toBeWithin(lineExpect[i][j], TOL, 'line ' + i + ' attr ' + d); + }); + }); + } + + it('draws lines and markers on enabled axes in the closest hovermode', function(done) { + gd = createGraphDiv(); + var _mock = makeMock(); + + Plotly.plot(gd, _mock).then(function() { + _hover({xval: 2, yval: 3}, 'xy'); + _assert( + [[80, 250, 1036, 250], [557, 100, 557, 401]] + ); + }) + .then(function() { + _hover({xval: 30, yval: 40}, 'x2y2'); + _assert( + [[651, 167, 988, 167], [820, 115, 820, 220]] + ); + }) + .catch(fail) + .then(done); + }); + + it('draws lines and markers on enabled axes in the x hovermode', function(done) { + gd = createGraphDiv(); + var _mock = makeMock(); + + _mock.layout.hovermode = 'x'; + + Plotly.plot(gd, _mock).then(function() { + _hover({xval: 2, yval: 3}, 'xy'); + _assert( + [[80, 250, 1036, 250], [557, 100, 557, 401]] + ); + }) + .then(function() { + _hover({xval: 30, yval: 40}, 'x2y2'); + _assert( + [[651, 167, 988, 167], [820, 115, 820, 220]] + ); + }) + .catch(fail) + .then(done); + }); + + it('draws lines and markers on enabled axes in the y hovermode', function(done) { + gd = createGraphDiv(); + var _mock = makeMock(); + + _mock.layout.hovermode = 'y'; + + Plotly.plot(gd, _mock).then(function() { + _hover({xval: 2, yval: 3}, 'xy'); + _assert( + [[80, 250, 1036, 250], [557, 100, 557, 401]] + ); + }) + .then(function() { + _hover({xval: 30, yval: 40}, 'x2y2'); + _assert( + [[652, 167, 988, 167], [820, 115, 820, 220]] + ); + }) + .catch(fail) + .then(done); + }); + + it('does not draw lines and markers on enabled axes if spikes are enabled on the same axes', function(done) { + gd = createGraphDiv(); + var _mock = makeMock(); + + _mock.layout.xaxis.showspikes = true; + + Plotly.plot(gd, _mock).then(function() { + _hover({xval: 2, yval: 3}, 'xy'); + _assert( + [[80, 250, 1036, 250]] + ); + }) + .then(function() { + _hover({xval: 30, yval: 40}, 'x2y2'); + _assert( + [[652, 167, 988, 167]] + ); + }) + .catch(fail) + .then(done); + }); + + it('does not draw lines and markers on enabled axes if spikes are enabled on the same axes', function(done) { + gd = createGraphDiv(); + var _mock = makeMock(); + + _mock.layout.yaxis.showspikes = true; + + Plotly.plot(gd, _mock).then(function() { + _hover({xval: 2, yval: 3}, 'xy'); + _assert( + [[557, 100, 557, 401]] + ); + }) + .then(function() { + _hover({xval: 30, yval: 40}, 'x2y2'); + _assert( + [[820, 115, 820, 220]] + ); + }) + .catch(fail) + .then(done); + }); + + it('draws lines and markers on enabled axes w/o tick labels', function(done) { + gd = createGraphDiv(); + var _mock = makeMock(); + + _mock.layout.xaxis.showticklabels = false; + _mock.layout.yaxis.showticklabels = false; + + Plotly.plot(gd, _mock).then(function() { + _hover({xval: 2, yval: 3}, 'xy'); + _assert( + [[80, 250, 1036, 250], [557, 100, 557, 401]] + ); + }) + .then(function() { + _hover({xval: 30, yval: 40}, 'x2y2'); + _assert( + [[652, 167, 988, 167], [820, 115, 820, 220]] + ); + }) + .catch(fail) + .then(done); + }); + }); +});