diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 7d76f671c54..44f36d01393 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -772,6 +772,11 @@ function createHoverText(hoverData, opts, gd) { var commonBgColor = commonLabelOpts.bgcolor || Color.defaultLine; var commonStroke = commonLabelOpts.bordercolor || Color.contrast(commonBgColor); var contrastColor = Color.contrast(commonBgColor); + var commonLabelFont = { + family: commonLabelOpts.font.family || fontFamily, + size: commonLabelOpts.font.size || fontSize, + color: commonLabelOpts.font.color || contrastColor + }; lpath.style({ fill: commonBgColor, @@ -779,41 +784,76 @@ function createHoverText(hoverData, opts, gd) { }); ltext.text(t0) - .call(Drawing.font, - commonLabelOpts.font.family || fontFamily, - commonLabelOpts.font.size || fontSize, - commonLabelOpts.font.color || contrastColor - ) + .call(Drawing.font, commonLabelFont) .call(svgTextUtils.positionText, 0, 0) .call(svgTextUtils.convertToTspans, gd); label.attr('transform', ''); var tbb = ltext.node().getBoundingClientRect(); + var lx, ly; + if(hovermode === 'x') { + var topsign = xa.side === 'top' ? '-' : ''; + ltext.attr('text-anchor', 'middle') .call(svgTextUtils.positionText, 0, (xa.side === 'top' ? (outerTop - tbb.bottom - HOVERARROWSIZE - HOVERTEXTPAD) : (outerTop - tbb.top + HOVERARROWSIZE + HOVERTEXTPAD))); - var topsign = xa.side === 'top' ? '-' : ''; - lpath.attr('d', 'M0,0' + - 'L' + HOVERARROWSIZE + ',' + topsign + HOVERARROWSIZE + - 'H' + (HOVERTEXTPAD + tbb.width / 2) + - 'v' + topsign + (HOVERTEXTPAD * 2 + tbb.height) + - 'H-' + (HOVERTEXTPAD + tbb.width / 2) + - 'V' + topsign + HOVERARROWSIZE + 'H-' + HOVERARROWSIZE + 'Z'); - - label.attr('transform', 'translate(' + - (xa._offset + (c0.x0 + c0.x1) / 2) + ',' + - (ya._offset + (xa.side === 'top' ? 0 : ya._length)) + ')'); + lx = xa._offset + (c0.x0 + c0.x1) / 2; + ly = ya._offset + (xa.side === 'top' ? 0 : ya._length); + + var halfWidth = tbb.width / 2 + HOVERTEXTPAD; + + if(lx < halfWidth) { + lx = halfWidth; + + lpath.attr('d', 'M-' + (halfWidth - HOVERARROWSIZE) + ',0' + + 'L-' + (halfWidth - HOVERARROWSIZE * 2) + ',' + topsign + HOVERARROWSIZE + + 'H' + (HOVERTEXTPAD + tbb.width / 2) + + 'v' + topsign + (HOVERTEXTPAD * 2 + tbb.height) + + 'H-' + halfWidth + + 'V' + topsign + HOVERARROWSIZE + + 'Z'); + } else if(lx > (fullLayout.width - halfWidth)) { + lx = fullLayout.width - halfWidth; + + lpath.attr('d', 'M' + (halfWidth - HOVERARROWSIZE) + ',0' + + 'L' + halfWidth + ',' + topsign + HOVERARROWSIZE + + 'v' + topsign + (HOVERTEXTPAD * 2 + tbb.height) + + 'H-' + halfWidth + + 'V' + topsign + HOVERARROWSIZE + + 'H' + (halfWidth - HOVERARROWSIZE * 2) + 'Z'); + } else { + lpath.attr('d', 'M0,0' + + 'L' + HOVERARROWSIZE + ',' + topsign + HOVERARROWSIZE + + 'H' + (HOVERTEXTPAD + tbb.width / 2) + + 'v' + topsign + (HOVERTEXTPAD * 2 + tbb.height) + + 'H-' + (HOVERTEXTPAD + tbb.width / 2) + + 'V' + topsign + HOVERARROWSIZE + + 'H-' + HOVERARROWSIZE + 'Z'); + } } else { - ltext.attr('text-anchor', ya.side === 'right' ? 'start' : 'end') - .call(svgTextUtils.positionText, - (ya.side === 'right' ? 1 : -1) * (HOVERTEXTPAD + HOVERARROWSIZE), - outerTop - tbb.top - tbb.height / 2); + var anchor; + var sgn; + var leftsign; + if(ya.side === 'right') { + anchor = 'start'; + sgn = 1; + leftsign = ''; + lx = xa._offset + xa._length; + } else { + anchor = 'end'; + sgn = -1; + leftsign = '-'; + lx = xa._offset; + } + + ly = ya._offset + (c0.y0 + c0.y1) / 2; + + ltext.attr('text-anchor', anchor); - var leftsign = ya.side === 'right' ? '' : '-'; lpath.attr('d', 'M0,0' + 'L' + leftsign + HOVERARROWSIZE + ',' + HOVERARROWSIZE + 'V' + (HOVERTEXTPAD + tbb.height / 2) + @@ -821,10 +861,49 @@ function createHoverText(hoverData, opts, gd) { 'V-' + (HOVERTEXTPAD + tbb.height / 2) + 'H' + leftsign + HOVERARROWSIZE + 'V-' + HOVERARROWSIZE + 'Z'); - label.attr('transform', 'translate(' + - (xa._offset + (ya.side === 'right' ? xa._length : 0)) + ',' + - (ya._offset + (c0.y0 + c0.y1) / 2) + ')'); + var halfHeight = tbb.height / 2; + var lty = outerTop - tbb.top - halfHeight; + var clipId = 'clip' + fullLayout._uid + 'commonlabel' + ya._id; + var clipPath; + + if(lx < (tbb.width + 2 * HOVERTEXTPAD + HOVERARROWSIZE)) { + clipPath = 'M-' + (HOVERARROWSIZE + HOVERTEXTPAD) + '-' + halfHeight + + 'h-' + (tbb.width - HOVERTEXTPAD) + + 'V' + halfHeight + + 'h' + (tbb.width - HOVERTEXTPAD) + 'Z'; + + var ltx = tbb.width - lx + HOVERTEXTPAD; + svgTextUtils.positionText(ltext, ltx, lty); + + // shift each line (except the longest) so that start-of-line + // is always visible + if(anchor === 'end') { + ltext.selectAll('tspan').each(function() { + var s = d3.select(this); + var dummy = Drawing.tester.append('text') + .text(s.text()) + .call(Drawing.font, commonLabelFont); + var dummyBB = dummy.node().getBoundingClientRect(); + if(dummyBB.width < tbb.width) { + s.attr('x', ltx - dummyBB.width); + } + dummy.remove(); + }); + } + } else { + svgTextUtils.positionText(ltext, sgn * (HOVERTEXTPAD + HOVERARROWSIZE), lty); + clipPath = null; + } + + var textClip = fullLayout._topclips.selectAll('#' + clipId).data(clipPath ? [0] : []); + textClip.enter().append('clipPath').attr('id', clipId).append('path'); + textClip.exit().remove(); + textClip.select('path').attr('d', clipPath); + Drawing.setClipUrl(ltext, clipPath ? clipId : null, gd); } + + label.attr('transform', 'translate(' + lx + ',' + ly + ')'); + // remove the "close but not quite" points // because of error bars, only take up to a space hoverData = hoverData.filter(function(d) { diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index 1f2dfd9464b..5a284112464 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -3,6 +3,8 @@ var d3 = require('d3'); var Plotly = require('@lib/index'); var Fx = require('@src/components/fx'); var Lib = require('@src/lib'); +var Drawing = require('@src/components/drawing'); + var HOVERMINTIME = require('@src/components/fx').constants.HOVERMINTIME; var MINUS_SIGN = require('@src/constants/numerical').MINUS_SIGN; @@ -14,6 +16,7 @@ var delay = require('../assets/delay'); var doubleClick = require('../assets/double_click'); var failTest = require('../assets/fail_test'); var touchEvent = require('../assets/touch_event'); +var negateIf = require('../assets/negate_if'); var customAssertions = require('../assets/custom_assertions'); var assertHoverLabelStyle = customAssertions.assertHoverLabelStyle; @@ -1700,6 +1703,122 @@ describe('hover info', function() { }); }); + describe('constraints info graph viewport', function() { + var gd; + + beforeEach(function() { gd = createGraphDiv(); }); + + it('hovermode:x common label should fit in the graph div width', function(done) { + function _assert(msg, exp) { + return function() { + var label = d3.select('g.axistext'); + if(label.node()) { + expect(label.text()).toBe(exp.txt, 'common label text| ' + msg); + expect(Drawing.getTranslate(label).x) + .toBeWithin(exp.lx, 5, 'common label translate-x| ' + msg); + + var startOfPath = label.select('path').attr('d').split('L')[0]; + expect(startOfPath).not.toBe('M0,0', 'offset start of label path| ' + msg); + } else { + fail('fail to generate common hover label'); + } + }; + } + + function _hoverLeft() { return _hover(gd, 30, 300); } + + function _hoverRight() { return _hover(gd, 370, 300); } + + Plotly.plot(gd, [{ + type: 'bar', + x: ['2019-01-01', '2019-06-01', '2020-01-01'], + y: [2, 5, 10] + }], { + xaxis: {range: ['2019-02-06', '2019-12-01']}, + margin: {l: 0, r: 0}, + width: 400, + height: 400 + }) + .then(_hoverLeft) + .then(_assert('left-edge hover', {txt: 'Jan 1, 2019', lx: 37})) + .then(_hoverRight) + .then(_assert('right-edge hover', {txt: 'Jan 1, 2020', lx: 362})) + .then(function() { return Plotly.relayout(gd, 'xaxis.side', 'top'); }) + .then(_hoverLeft) + .then(_assert('left-edge hover (side:top)', {txt: 'Jan 1, 2019', lx: 37})) + .then(_hoverRight) + .then(_assert('right-edge hover (side:top)', {txt: 'Jan 1, 2020', lx: 362})) + .catch(failTest) + .then(done); + }); + + it('hovermode:y common label should shift and clip text start into graph div', function(done) { + function _assert(msg, exp) { + return function() { + var label = d3.select('g.axistext'); + if(label.node()) { + var ltext = label.select('text'); + expect(ltext.text()).toBe(exp.txt, 'common label text| ' + msg); + expect(ltext.attr('x')).toBeWithin(exp.ltx, 5, 'common label text x| ' + msg); + + negateIf(exp.clip, expect(ltext.attr('clip-path'))).toBe(null, 'text clip url| ' + msg); + + var fullLayout = gd._fullLayout; + var clipId = 'clip' + fullLayout._uid + 'commonlabely'; + var clipPath = d3.select('#' + clipId); + negateIf(exp.clip, expect(clipPath.node())).toBe(null, 'text clip path|' + msg); + + if(exp.tspanX) { + var tspans = label.selectAll('tspan'); + if(tspans.size()) { + tspans.each(function(d, i) { + var s = d3.select(this); + expect(s.attr('x')).toBeWithin(exp.tspanX[i], 5, i + '- tspan shift| ' + msg); + }); + } else { + fail('fail to generate tspans in hover label'); + } + } + } else { + fail('fail to generate common hover label'); + } + }; + } + + function _hoverWayLong() { return _hover(gd, 135, 100); } + + function _hoverA() { return _hover(gd, 135, 20); } + + Plotly.plot(gd, [{ + type: 'bar', + orientation: 'h', + y: ['Looong label', 'Loooooger label', 'Waay loooong label', 'a'], + x: [2, 5, 10, 2] + }], { + width: 400, + height: 400 + }) + .then(_hoverWayLong) + .then(_assert('on way long label', {txt: 'Waay loooong label', clip: true, ltx: 38})) + .then(_hoverA) + .then(_assert('on "a" label', {txt: 'a', clip: false, ltx: -9})) + .then(function() { + return Plotly.restyle(gd, { + y: [['Looong label', 'Loooooger label', 'SHORT!
Waay loooong label', 'a']] + }); + }) + .then(_hoverWayLong) + .then(_assert('on way long label (multi-line case)', { + txt: 'SHORT!Waay loooong label', + clip: true, + ltx: 38, + tspanX: [-11, 38] + })) + .catch(failTest) + .then(done); + }); + }); + describe('hovertemplate', function() { var mockCopy;