diff --git a/draftlogs/6947_add.md b/draftlogs/6947_add.md new file mode 100644 index 00000000000..5a6a20c4bbe --- /dev/null +++ b/draftlogs/6947_add.md @@ -0,0 +1 @@ + - Add `layout.hoversubplots` to enable hover effects across multiple cartesian suplots sharing one axis [[#6947](https://github.com/plotly/plotly.js/pull/6947)] diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index c04b0a7f65b..4165bbaeab3 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -5,6 +5,7 @@ var isNumeric = require('fast-isnumeric'); var tinycolor = require('tinycolor2'); var Lib = require('../../lib'); +var pushUnique = Lib.pushUnique; var strTranslate = Lib.strTranslate; var strRotate = Lib.strRotate; var Events = require('../../lib/events'); @@ -257,13 +258,40 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { // use those instead of finding overlayed plots var subplots = Array.isArray(subplot) ? subplot : [subplot]; + var spId; + var fullLayout = gd._fullLayout; + var hoversubplots = fullLayout.hoversubplots; var plots = fullLayout._plots || []; var plotinfo = plots[subplot]; var hasCartesian = fullLayout._has('cartesian'); + var hovermode = evt.hovermode || fullLayout.hovermode; + var hovermodeHasX = (hovermode || '').charAt(0) === 'x'; + var hovermodeHasY = (hovermode || '').charAt(0) === 'y'; + + if(hasCartesian && (hovermodeHasX || hovermodeHasY) && hoversubplots === 'axis') { + var subplotsLength = subplots.length; + for(var p = 0; p < subplotsLength; p++) { + spId = subplots[p]; + if(plots[spId]) { + // 'cartesian' case + + var subplotsWith = ( + Axes.getFromId(gd, spId, hovermodeHasX ? 'x' : 'y') + )._subplotsWith; + + if(subplotsWith && subplotsWith.length) { + for(var q = 0; q < subplotsWith.length; q++) { + pushUnique(subplots, subplotsWith[q]); + } + } + } + } + } + // list of all overlaid subplots to look at - if(plotinfo) { + if(plotinfo && hoversubplots !== 'single') { var overlayedSubplots = plotinfo.overlays.map(function(pi) { return pi.id; }); @@ -277,7 +305,7 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { var supportsCompare = false; for(var i = 0; i < len; i++) { - var spId = subplots[i]; + spId = subplots[i]; if(plots[spId]) { // 'cartesian' case @@ -295,8 +323,6 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { } } - var hovermode = evt.hovermode || fullLayout.hovermode; - if(hovermode && !supportsCompare) hovermode = 'closest'; if(['x', 'y', 'closest', 'x unified', 'y unified'].indexOf(hovermode) === -1 || !gd.calcdata || @@ -441,6 +467,12 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { // the rest of this function from running and failing if(['carpet', 'contourcarpet'].indexOf(trace._module.name) !== -1) continue; + // within one trace mode can sometimes be overridden + _mode = hovermode; + if(helpers.isUnifiedHover(_mode)) { + _mode = _mode.charAt(0); + } + if(trace.type === 'splom') { // splom traces do not generate overlay subplots, // it is safe to assume here splom traces correspond to the 0th subplot @@ -451,12 +483,6 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { subploti = subplots.indexOf(subplotId); } - // within one trace mode can sometimes be overridden - _mode = hovermode; - if(helpers.isUnifiedHover(_mode)) { - _mode = _mode.charAt(0); - } - // container for new point, also used to pass info into module.hoverPoints pointData = { // trace properties @@ -508,8 +534,6 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { pointData.scene = fullLayout._splomScenes[trace.uid]; } - closedataPreviousLength = hoverData.length; - // for a highlighting array, figure out what // we're searching for with this element if(_mode === 'array') { @@ -536,12 +560,18 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { yval = yvalArray[subploti]; } + closedataPreviousLength = hoverData.length; + // 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, { finiteRange: true, - hoverLayer: fullLayout._hoverlayer + hoverLayer: fullLayout._hoverlayer, + + // options for splom when hovering on same axis + hoversubplots: hoversubplots, + gd: gd }); if(newPoints) { @@ -662,7 +692,9 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { gd._spikepoints = newspikepoints; var sortHoverData = function() { - hoverData.sort(function(d1, d2) { return d1.distance - d2.distance; }); + if(hoversubplots !== 'axis') { + hoverData.sort(function(d1, d2) { return d1.distance - d2.distance; }); + } // move period positioned points and box/bar-like traces to the end of the list hoverData = orderRangePoints(hoverData, hovermode); diff --git a/src/components/fx/hovermode_defaults.js b/src/components/fx/hovermode_defaults.js index e9a038bc19e..edb705e02cc 100644 --- a/src/components/fx/hovermode_defaults.js +++ b/src/components/fx/hovermode_defaults.js @@ -12,5 +12,6 @@ module.exports = function handleHoverModeDefaults(layoutIn, layoutOut) { } coerce('clickmode'); + coerce('hoversubplots'); return coerce('hovermode'); }; diff --git a/src/components/fx/layout_attributes.js b/src/components/fx/layout_attributes.js index af5ce341e21..0cb8750605b 100644 --- a/src/components/fx/layout_attributes.js +++ b/src/components/fx/layout_attributes.js @@ -78,6 +78,19 @@ module.exports = { 'If false, hover interactions are disabled.' ].join(' ') }, + hoversubplots: { + valType: 'enumerated', + values: ['single', 'overlaying', 'axis'], + dflt: 'overlaying', + editType: 'none', + description: [ + 'Determines expansion of hover effects to other subplots', + 'If *single* just the axis pair of the primary point is included without overlaying subplots.', + 'If *overlaying* all subplots using the main axis and occupying the same space are included.', + 'If *axis*, also include stacked subplots using the same axis', + 'when `hovermode` is set to *x*, *x unified*, *y* or *y unified*.', + ].join(' ') + }, hoverdistance: { valType: 'integer', min: -1, diff --git a/src/traces/splom/hover.js b/src/traces/splom/hover.js index 1ae60244525..eef4b23f40d 100644 --- a/src/traces/splom/hover.js +++ b/src/traces/splom/hover.js @@ -2,16 +2,64 @@ var helpers = require('./helpers'); var calcHover = require('../scattergl/hover').calcHover; +var getFromId = require('../../plots/cartesian/axes').getFromId; +var extendFlat = require('../../lib/extend').extendFlat; -function hoverPoints(pointData, xval, yval) { +function hoverPoints(pointData, xval, yval, hovermode, opts) { + if(!opts) opts = {}; + + var hovermodeHasX = (hovermode || '').charAt(0) === 'x'; + var hovermodeHasY = (hovermode || '').charAt(0) === 'y'; + + var xpx = pointData.xa.c2p(xval); + var ypx = pointData.ya.c2p(yval); + + var points = _hoverPoints(pointData, xpx, ypx); + + if((hovermodeHasX || hovermodeHasY) && opts.hoversubplots === 'axis') { + var _xpx = points[0]._xpx; + var _ypx = points[0]._ypx; + + if( + (hovermodeHasX && _xpx !== undefined) || + (hovermodeHasY && _ypx !== undefined) + ) { + var subplotsWith = ( + hovermodeHasX ? + pointData.xa : + pointData.ya + )._subplotsWith; + + var gd = opts.gd; + + var _pointData = extendFlat({}, pointData); + + for(var i = 0; i < subplotsWith.length; i++) { + var spId = subplotsWith[i]; + + if(hovermodeHasY) { + _pointData.xa = getFromId(gd, spId, 'x'); + } else { // hovermodeHasX + _pointData.ya = getFromId(gd, spId, 'y'); + } + + var newPoints = _hoverPoints(_pointData, _xpx, _ypx, hovermodeHasX, hovermodeHasY); + + points = points.concat(newPoints); + } + } + } + + return points; +} + +function _hoverPoints(pointData, xpx, ypx, hoversubplotsX, hoversubplotsY) { var cd = pointData.cd; var trace = cd[0].trace; var scene = pointData.scene; var cdata = scene.matrixOptions.cdata; var xa = pointData.xa; var ya = pointData.ya; - var xpx = xa.c2p(xval); - var ypx = ya.c2p(yval); var maxDistance = pointData.distance; var xi = helpers.getDimIndex(trace, xa); @@ -21,19 +69,36 @@ function hoverPoints(pointData, xval, yval) { var x = cdata[xi]; var y = cdata[yi]; - var id, dxy; + var id, dxy, _xpx, _ypx; var minDist = maxDistance; for(var i = 0; i < x.length; i++) { + if((hoversubplotsX || hoversubplotsY) && i !== pointData.index) continue; + var ptx = x[i]; var pty = y[i]; - var dx = xa.c2p(ptx) - xpx; - var dy = ya.c2p(pty) - ypx; - var dist = Math.sqrt(dx * dx + dy * dy); + var thisXpx = xa.c2p(ptx); + var thisYpx = ya.c2p(pty); + + var dx = thisXpx - xpx; + var dy = thisYpx - ypx; + var dist = 0; + + var pick = false; + if(hoversubplotsX) { + if(dx === 0) pick = true; + } else if(hoversubplotsY) { + if(dy === 0) pick = true; + } else { + dist = Math.sqrt(dx * dx + dy * dy); + if(dist < minDist) pick = true; + } - if(dist < minDist) { + if(pick) { minDist = dxy = dist; id = i; + _xpx = thisXpx; + _ypx = thisYpx; } } @@ -43,7 +108,10 @@ function hoverPoints(pointData, xval, yval) { if(id === undefined) return [pointData]; - return [calcHover(pointData, x, y, trace)]; + var out = calcHover(pointData, x, y, trace); + out._xpx = _xpx; + out._ypx = _ypx; + return [out]; } module.exports = { diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index 165b90912f5..1fd2b8b461f 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -28,6 +28,8 @@ var assertElemInside = customAssertions.assertElemInside; var groupTitlesMock = require('../../image/mocks/legendgroup-titles'); +var splomLogMock = require('../../image/mocks/splom_log'); + function touch(path, options) { var len = path.length; Lib.clearThrottle(); @@ -2378,6 +2380,386 @@ describe('hover info on stacked subplots', function() { }); }); +describe('hover on subplots when hoversubplots is set to *single* and x hovermodes', function() { + 'use strict'; + + var mock = { + layout: { + hoversubplots: 'single', + hovermode: 'x', + yaxis2: { + anchor: 'x', + overlaying: 'y' + } + }, + + data: [ + { + y: [1, 2, 3] + }, + { + y: [1, 3, 2], + yaxis: 'y2' + } + ], + }; + + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + Plotly.newPlot(gd, mock).then(done); + }); + + afterEach(destroyGraphDiv); + + it('hovermode: *x* | *x unified* with hoversubplots: *axis*', function() { + var pos = 0; + var subplot = 'xy'; + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {xval: pos}, subplot); + expect(gd._hoverdata.length).toBe(1); + assertHoverLabelContent({ + nums: '1', + name: 'trace 0', + axis: String(pos) + }); + + pos = 0; + subplot = 'xy2'; + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {xval: pos}, subplot); + expect(gd._hoverdata.length).toBe(1); + assertHoverLabelContent({ + nums: '1', + name: 'trace 1', + axis: String(pos) + }); + + Plotly.relayout(gd, 'hovermode', 'x unified'); + pos = 0; + subplot = 'xy'; + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {xval: pos}, subplot); + expect(gd._hoverdata.length).toBe(1); + }); +}); + +describe('hover on subplots when hoversubplots is set to *axis* and x hovermodes', function() { + 'use strict'; + + var mock = { + layout: { + hoversubplots: 'axis', + hovermode: 'x', + grid: { + rows: 3, + columns: 2, + pattern: 'coupled' + } + }, + + data: [ + { + y: [1, 2, 3] + }, + { + y: [10, 20, 30], + yaxis: 'y2' + }, + { + y: [100, 200, 300], + yaxis: 'y3' + }, + { + y: [10, 20, 30], + xaxis: 'x2', + yaxis: 'y2' + } + ], + }; + + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + Plotly.newPlot(gd, mock).then(done); + }); + + afterEach(destroyGraphDiv); + + it('hovermode: *x* | *x unified* with hoversubplots: *axis*', function() { + var pos = 0; + var subplot = 'xy'; + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {xval: pos}, subplot); + expect(gd._hoverdata.length).toBe(3); + assertHoverLabelContent({ + nums: ['1', '10', '100'], + name: ['trace 0', 'trace 1', 'trace 2'], + axis: String([pos]) + }); + + pos = 1; + subplot = 'xy2'; + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {xval: pos}, subplot); + + expect(gd._hoverdata.length).toBe(3); + assertHoverLabelContent({ + nums: ['2', '20', '200'], + name: ['trace 0', 'trace 1', 'trace 2'], + axis: String(pos) + }); + + pos = 2; + subplot = 'xy3'; + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {xval: pos}, subplot); + + expect(gd._hoverdata.length).toBe(3); + assertHoverLabelContent({ + nums: ['3', '30', '300'], + name: ['trace 0', 'trace 1', 'trace 2'], + axis: String(pos) + }); + + Plotly.relayout(gd, 'hovermode', 'x unified'); + pos = 0; + subplot = 'xy'; + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {xval: pos}, subplot); + expect(gd._hoverdata.length).toBe(3); + }); +}); + +describe('hover on subplots when hoversubplots is set to *axis* and y hovermodes', function() { + 'use strict'; + + var mock = { + layout: { + hoversubplots: 'axis', + hovermode: 'y', + grid: { + rows: 2, + columns: 3, + pattern: 'coupled' + } + }, + + data: [ + { + x: [1, 2, 3] + }, + { + x: [10, 20, 30], + xaxis: 'x2' + }, + { + x: [100, 200, 300], + xaxis: 'x3' + }, + { + x: [10, 20, 30], + xaxis: 'x2', + yaxis: 'y2' + } + ], + }; + + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + Plotly.newPlot(gd, mock).then(done); + }); + + afterEach(destroyGraphDiv); + + it('hovermode: *y* | *y unified* with hoversubplots: *axis*', function() { + var pos = 0; + var subplot = 'xy'; + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {yval: pos}, subplot); + expect(gd._hoverdata.length).toBe(3); + assertHoverLabelContent({ + nums: ['1', '10', '100'], + name: ['trace 0', 'trace 1', 'trace 2'], + axis: String([pos]) + }); + + pos = 1; + subplot = 'x2y'; + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {yval: pos}, subplot); + + expect(gd._hoverdata.length).toBe(3); + assertHoverLabelContent({ + nums: ['2', '20', '200'], + name: ['trace 0', 'trace 1', 'trace 2'], + axis: String(pos) + }); + + pos = 2; + subplot = 'x3y'; + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {yval: pos}, subplot); + + expect(gd._hoverdata.length).toBe(3); + assertHoverLabelContent({ + nums: ['3', '30', '300'], + name: ['trace 0', 'trace 1', 'trace 2'], + axis: String(pos) + }); + + Plotly.relayout(gd, 'hovermode', 'y unified'); + pos = 0; + subplot = 'xy'; + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {yval: pos}, subplot); + expect(gd._hoverdata.length).toBe(3); + }); +}); + +describe('splom hover on subplots when hoversubplots is set to *axis* and (x|y) hovermodes', function() { + 'use strict'; + + var mock = Lib.extendDeep({}, splomLogMock); + mock.layout.hovermode = 'x'; + mock.layout.hoversubplots = 'axis'; + + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + Plotly.newPlot(gd, mock).then(done); + }); + + afterEach(destroyGraphDiv); + + it('splom hoversubplots: *axis*', function() { + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {x: 200, y: 200}, 'xy'); + expect(gd._hoverdata.length).toBe(3); + assertHoverLabelContent({ + nums: ['100', '100k'], + name: ['', ''], + axis: '100' + }); + + Plotly.relayout(gd, 'hovermode', 'x unified'); + + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {x: 200, y: 200}, 'xy'); + expect(gd._hoverdata.length).toBe(3); + + Plotly.relayout(gd, 'hovermode', 'y unified'); + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {x: 200, y: 200}, 'xy'); + expect(gd._hoverdata.length).toBe(3); + }); +}); + +describe('splom hover *axis* hoversubplots splom points on same position should pick points with same index', function() { + 'use strict'; + + var mock = { + data: [{ + type: 'splom', + dimensions: [{ + values: [1, 1, 1, 1] + }, { + values: [1, 2, 3, 4] + }, { + values: [1, 2, 3, 4] + }, { + values: [1, null, 3, 4] + } + ]}], + layout: { + hoversubplots: 'axis', + hovermode: 'x', + width: 600, + height: 600 + } + }; + + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + Plotly.newPlot(gd, mock).then(done); + }); + + afterEach(destroyGraphDiv); + + it('splom *axis* hoversubplots', function() { + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {}, 'xy'); + expect(gd._hoverdata.length).toBe(5); + assertHoverLabelContent({ + nums: ['1', '1', '1', '1'], + name: ['', '', '', ''], + axis: '1' + }); + + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {}, 'xy2'); + expect(gd._hoverdata.length).toBe(4); + assertHoverLabelContent({ + nums: ['1', '2', '2'], + name: ['', '', ''], + axis: '1' + }); + + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {}, 'xy3'); + expect(gd._hoverdata.length).toBe(4); + assertHoverLabelContent({ + nums: ['1', '2', '2'], + name: ['', '', ''], + axis: '1' + }); + + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {}, 'xy4'); + expect(gd._hoverdata.length).toBe(5); + assertHoverLabelContent({ + nums: ['1', '3', '3', '3'], + name: ['', '', '', ''], + axis: '1' + }); + + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {}, 'x2y'); + expect(gd._hoverdata.length).toBe(5); + assertHoverLabelContent({ + nums: ['1', '3', '3', '3'], + name: ['', '', '', ''], + axis: '3' + }); + + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {}, 'x3y'); + expect(gd._hoverdata.length).toBe(5); + assertHoverLabelContent({ + nums: ['1', '3', '3', '3'], + name: ['', '', '', ''], + axis: '3' + }); + + Lib.clearThrottle(); + Plotly.Fx.hover(gd, {}, 'x4y'); + expect(gd._hoverdata.length).toBe(5); + assertHoverLabelContent({ + nums: ['1', '3', '3', '3'], + name: ['', '', '', ''], + axis: '3' + }); + }); +}); + + describe('hover on many lines+bars', function() { 'use strict'; diff --git a/test/plot-schema.json b/test/plot-schema.json index 334c9a918ed..727cfea0c85 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -2634,6 +2634,17 @@ "y unified" ] }, + "hoversubplots": { + "description": "Determines expansion of hover effects to other subplots If *single* just the axis pair of the primary point is included without overlaying subplots. If *overlaying* all subplots using the main axis and occupying the same space are included. If *axis*, also include stacked subplots using the same axis when `hovermode` is set to *x*, *x unified*, *y* or *y unified*.", + "dflt": "overlaying", + "editType": "none", + "valType": "enumerated", + "values": [ + "single", + "overlaying", + "axis" + ] + }, "images": { "items": { "image": {