diff --git a/src/components/fx/attributes.js b/src/components/fx/attributes.js index a2548f9eccd..c4b49a3d55b 100644 --- a/src/components/fx/attributes.js +++ b/src/components/fx/attributes.js @@ -21,6 +21,7 @@ module.exports = { }), align: extendFlat({}, hoverLabelAttrs.align, {arrayOk: true}), namelength: extendFlat({}, hoverLabelAttrs.namelength, {arrayOk: true}), + showarrow: extendFlat({}, hoverLabelAttrs.showarrow, {arrayOk: true}), editType: 'none' } }; diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 80fc4a07cf3..0794a0b5470 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -913,7 +913,7 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { if(!helpers.isUnifiedHover(hovermode)) { hoverAvoidOverlaps(hoverLabels, rotateLabels, fullLayout, hoverText.commonLabelBoundingBox); - alignHoverText(hoverLabels, rotateLabels, fullLayout._invScaleX, fullLayout._invScaleY); + alignHoverText(hoverLabels, rotateLabels, fullLayout._invScaleX, fullLayout._invScaleY, fullLayout.hoverlabel.showarrow); } // TODO: tagName hack is needed to appease geo.js's hack of using eventTarget=true // we should improve the "fx" API so other plots can use it without these hack. if(eventTarget && eventTarget.tagName) { @@ -1903,7 +1903,7 @@ function getTextShiftX(hoverLabel) { }; } -function alignHoverText(hoverLabels, rotateLabels, scaleX, scaleY) { +function alignHoverText(hoverLabels, rotateLabels, scaleX, scaleY, showArrow) { var pX = function(x) { return x * scaleX; }; var pY = function(y) { return y * scaleY; }; @@ -1923,19 +1923,26 @@ function alignHoverText(hoverLabels, rotateLabels, scaleX, scaleY) { var isMiddle = anchor === 'middle'; - g.select('path') - .attr('d', isMiddle ? + var pathStr; + if(isMiddle) { // middle aligned: rect centered on data - ('M-' + pX(d.bx / 2 + d.tx2width / 2) + ',' + pY(offsetY - d.by / 2) + - 'h' + pX(d.bx) + 'v' + pY(d.by) + 'h-' + pX(d.bx) + 'Z') : + pathStr = 'M-' + pX(d.bx / 2 + d.tx2width / 2) + ',' + pY(offsetY - d.by / 2) + + 'h' + pX(d.bx) + 'v' + pY(d.by) + 'h-' + pX(d.bx) + 'Z'; + } else if(showArrow !== false) { // left or right aligned: side rect with arrow to data - ('M0,0L' + pX(horzSign * HOVERARROWSIZE + offsetX) + ',' + pY(HOVERARROWSIZE + offsetY) + - 'v' + pY(d.by / 2 - HOVERARROWSIZE) + - 'h' + pX(horzSign * d.bx) + - 'v-' + pY(d.by) + - 'H' + pX(horzSign * HOVERARROWSIZE + offsetX) + - 'V' + pY(offsetY - HOVERARROWSIZE) + - 'Z')); + pathStr = 'M0,0L' + pX(horzSign * HOVERARROWSIZE + offsetX) + ',' + pY(HOVERARROWSIZE + offsetY) + + 'v' + pY(d.by / 2 - HOVERARROWSIZE) + + 'h' + pX(horzSign * d.bx) + + 'v-' + pY(d.by) + + 'H' + pX(horzSign * HOVERARROWSIZE + offsetX) + + 'V' + pY(offsetY - HOVERARROWSIZE) + + 'Z'; + } else { + // left or right aligned: side rect without arrow + pathStr = 'M' + pX(horzSign * HOVERARROWSIZE + offsetX) + ',' + pY(offsetY - d.by / 2) + + 'h' + pX(horzSign * d.bx) + 'v' + pY(d.by) + 'h' + pX(-horzSign * d.bx) + 'Z'; + } + g.select('path').attr('d', pathStr); var posX = offsetX + shiftX.textShiftX; var posY = offsetY + d.ty0 - d.by / 2 + HOVERTEXTPAD; diff --git a/src/components/fx/hoverlabel_defaults.js b/src/components/fx/hoverlabel_defaults.js index 043758e740a..0a0b87ac5c3 100644 --- a/src/components/fx/hoverlabel_defaults.js +++ b/src/components/fx/hoverlabel_defaults.js @@ -36,6 +36,7 @@ module.exports = function handleHoverLabelDefaults(contIn, contOut, coerce, opts coerce('hoverlabel.bgcolor', opts.bgcolor); coerce('hoverlabel.bordercolor', opts.bordercolor); coerce('hoverlabel.namelength', opts.namelength); + coerce('hoverlabel.showarrow', opts.showarrow); Lib.coerceFont(coerce, 'hoverlabel.font', opts.font); coerce('hoverlabel.align', opts.align); }; diff --git a/src/components/fx/layout_attributes.js b/src/components/fx/layout_attributes.js index 0cb8750605b..825b02af289 100644 --- a/src/components/fx/layout_attributes.js +++ b/src/components/fx/layout_attributes.js @@ -165,6 +165,15 @@ module.exports = { '`namelength - 3` characters and add an ellipsis.' ].join(' ') }, + showarrow: { + valType: 'boolean', + dflt: true, + editType: 'none', + description: [ + 'Sets whether or not to show the hover label arrow/triangle', + 'pointing to the data point.' + ].join(' ') + }, editType: 'none' }, diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index a9af59100c0..24109f4947d 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -7038,3 +7038,100 @@ describe('hover on traces with (x|y)hoverformat', function() { .then(done, done.fail); }); }); + +describe('hoverlabel.showarrow', function() { + 'use strict'; + + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function _hover(x, y) { + mouseEvent('mousemove', x, y); + Lib.clearThrottle(); + } + + function getHoverPath() { + var hoverLabels = d3SelectAll('g.hovertext'); + if (hoverLabels.size() === 0) return null; + return hoverLabels.select('path').attr('d'); + } + + it('should show hover arrow by default', function(done) { + Plotly.newPlot(gd, [{ + x: [1, 2, 3], + y: [1, 2, 1], + type: 'scatter', + mode: 'markers' + }], { + width: 400, + height: 400, + margin: {l: 50, t: 50, r: 50, b: 50} + }) + .then(function() { + _hover(200, 200); // Hover over middle point + }) + .then(delay(HOVERMINTIME * 1.1)) + .then(function() { + var pathD = getHoverPath(); + expect(pathD).not.toBeNull('hover path should exist'); + // Arrow paths contain 'L' commands for the triangular pointer + expect(pathD).toMatch(/M0,0L/, 'path should contain arrow (L command from origin)'); + }) + .then(done, done.fail); + }); + + it('should hide hover arrow when showarrow is false', function(done) { + Plotly.newPlot(gd, [{ + x: [1, 2, 3], + y: [1, 2, 1], + type: 'scatter', + mode: 'markers' + }], { + width: 400, + height: 400, + margin: {l: 50, t: 50, r: 50, b: 50}, + hoverlabel: { showarrow: false } + }) + .then(function() { + _hover(200, 200); // Hover over middle point + }) + .then(delay(HOVERMINTIME * 1.1)) + .then(function() { + var pathD = getHoverPath(); + expect(pathD).not.toBeNull('hover path should exist'); + // No-arrow paths should be simple rectangles (no 'L' commands from origin) + expect(pathD).not.toMatch(/M0,0L/, 'path should not contain arrow'); + expect(pathD).toMatch(/^M-/, 'path should start with rectangle (M- for left edge)'); + }) + .then(done, done.fail); + }); + + it('should work at trace level', function(done) { + Plotly.newPlot(gd, [{ + x: [1, 2, 3], + y: [1, 2, 1], + type: 'scatter', + mode: 'markers', + hoverlabel: { showarrow: false } + }], { + width: 400, + height: 400, + margin: {l: 50, t: 50, r: 50, b: 50} + }) + .then(function() { + _hover(200, 200); // Hover over middle point + }) + .then(delay(HOVERMINTIME * 1.1)) + .then(function() { + var pathD = getHoverPath(); + expect(pathD).not.toBeNull('hover path should exist'); + expect(pathD).not.toMatch(/M0,0L/, 'trace-level showarrow:false should hide arrow'); + }) + .then(done, done.fail); + }); +});