Skip to content

Commit c8a9f8a

Browse files
authored
Merge pull request #4298 from plotly/common-label-overlaps
Workarounds for "common" (aka axis) hover label clipping
2 parents 3b6832e + 8d79522 commit c8a9f8a

File tree

2 files changed

+222
-24
lines changed

2 files changed

+222
-24
lines changed

src/components/fx/hover.js

+103-24
Original file line numberDiff line numberDiff line change
@@ -772,59 +772,138 @@ function createHoverText(hoverData, opts, gd) {
772772
var commonBgColor = commonLabelOpts.bgcolor || Color.defaultLine;
773773
var commonStroke = commonLabelOpts.bordercolor || Color.contrast(commonBgColor);
774774
var contrastColor = Color.contrast(commonBgColor);
775+
var commonLabelFont = {
776+
family: commonLabelOpts.font.family || fontFamily,
777+
size: commonLabelOpts.font.size || fontSize,
778+
color: commonLabelOpts.font.color || contrastColor
779+
};
775780

776781
lpath.style({
777782
fill: commonBgColor,
778783
stroke: commonStroke
779784
});
780785

781786
ltext.text(t0)
782-
.call(Drawing.font,
783-
commonLabelOpts.font.family || fontFamily,
784-
commonLabelOpts.font.size || fontSize,
785-
commonLabelOpts.font.color || contrastColor
786-
)
787+
.call(Drawing.font, commonLabelFont)
787788
.call(svgTextUtils.positionText, 0, 0)
788789
.call(svgTextUtils.convertToTspans, gd);
789790

790791
label.attr('transform', '');
791792

792793
var tbb = ltext.node().getBoundingClientRect();
794+
var lx, ly;
795+
793796
if(hovermode === 'x') {
797+
var topsign = xa.side === 'top' ? '-' : '';
798+
794799
ltext.attr('text-anchor', 'middle')
795800
.call(svgTextUtils.positionText, 0, (xa.side === 'top' ?
796801
(outerTop - tbb.bottom - HOVERARROWSIZE - HOVERTEXTPAD) :
797802
(outerTop - tbb.top + HOVERARROWSIZE + HOVERTEXTPAD)));
798803

799-
var topsign = xa.side === 'top' ? '-' : '';
800-
lpath.attr('d', 'M0,0' +
801-
'L' + HOVERARROWSIZE + ',' + topsign + HOVERARROWSIZE +
802-
'H' + (HOVERTEXTPAD + tbb.width / 2) +
803-
'v' + topsign + (HOVERTEXTPAD * 2 + tbb.height) +
804-
'H-' + (HOVERTEXTPAD + tbb.width / 2) +
805-
'V' + topsign + HOVERARROWSIZE + 'H-' + HOVERARROWSIZE + 'Z');
806-
807-
label.attr('transform', 'translate(' +
808-
(xa._offset + (c0.x0 + c0.x1) / 2) + ',' +
809-
(ya._offset + (xa.side === 'top' ? 0 : ya._length)) + ')');
804+
lx = xa._offset + (c0.x0 + c0.x1) / 2;
805+
ly = ya._offset + (xa.side === 'top' ? 0 : ya._length);
806+
807+
var halfWidth = tbb.width / 2 + HOVERTEXTPAD;
808+
809+
if(lx < halfWidth) {
810+
lx = halfWidth;
811+
812+
lpath.attr('d', 'M-' + (halfWidth - HOVERARROWSIZE) + ',0' +
813+
'L-' + (halfWidth - HOVERARROWSIZE * 2) + ',' + topsign + HOVERARROWSIZE +
814+
'H' + (HOVERTEXTPAD + tbb.width / 2) +
815+
'v' + topsign + (HOVERTEXTPAD * 2 + tbb.height) +
816+
'H-' + halfWidth +
817+
'V' + topsign + HOVERARROWSIZE +
818+
'Z');
819+
} else if(lx > (fullLayout.width - halfWidth)) {
820+
lx = fullLayout.width - halfWidth;
821+
822+
lpath.attr('d', 'M' + (halfWidth - HOVERARROWSIZE) + ',0' +
823+
'L' + halfWidth + ',' + topsign + HOVERARROWSIZE +
824+
'v' + topsign + (HOVERTEXTPAD * 2 + tbb.height) +
825+
'H-' + halfWidth +
826+
'V' + topsign + HOVERARROWSIZE +
827+
'H' + (halfWidth - HOVERARROWSIZE * 2) + 'Z');
828+
} else {
829+
lpath.attr('d', 'M0,0' +
830+
'L' + HOVERARROWSIZE + ',' + topsign + HOVERARROWSIZE +
831+
'H' + (HOVERTEXTPAD + tbb.width / 2) +
832+
'v' + topsign + (HOVERTEXTPAD * 2 + tbb.height) +
833+
'H-' + (HOVERTEXTPAD + tbb.width / 2) +
834+
'V' + topsign + HOVERARROWSIZE +
835+
'H-' + HOVERARROWSIZE + 'Z');
836+
}
810837
} else {
811-
ltext.attr('text-anchor', ya.side === 'right' ? 'start' : 'end')
812-
.call(svgTextUtils.positionText,
813-
(ya.side === 'right' ? 1 : -1) * (HOVERTEXTPAD + HOVERARROWSIZE),
814-
outerTop - tbb.top - tbb.height / 2);
838+
var anchor;
839+
var sgn;
840+
var leftsign;
841+
if(ya.side === 'right') {
842+
anchor = 'start';
843+
sgn = 1;
844+
leftsign = '';
845+
lx = xa._offset + xa._length;
846+
} else {
847+
anchor = 'end';
848+
sgn = -1;
849+
leftsign = '-';
850+
lx = xa._offset;
851+
}
852+
853+
ly = ya._offset + (c0.y0 + c0.y1) / 2;
854+
855+
ltext.attr('text-anchor', anchor);
815856

816-
var leftsign = ya.side === 'right' ? '' : '-';
817857
lpath.attr('d', 'M0,0' +
818858
'L' + leftsign + HOVERARROWSIZE + ',' + HOVERARROWSIZE +
819859
'V' + (HOVERTEXTPAD + tbb.height / 2) +
820860
'h' + leftsign + (HOVERTEXTPAD * 2 + tbb.width) +
821861
'V-' + (HOVERTEXTPAD + tbb.height / 2) +
822862
'H' + leftsign + HOVERARROWSIZE + 'V-' + HOVERARROWSIZE + 'Z');
823863

824-
label.attr('transform', 'translate(' +
825-
(xa._offset + (ya.side === 'right' ? xa._length : 0)) + ',' +
826-
(ya._offset + (c0.y0 + c0.y1) / 2) + ')');
864+
var halfHeight = tbb.height / 2;
865+
var lty = outerTop - tbb.top - halfHeight;
866+
var clipId = 'clip' + fullLayout._uid + 'commonlabel' + ya._id;
867+
var clipPath;
868+
869+
if(lx < (tbb.width + 2 * HOVERTEXTPAD + HOVERARROWSIZE)) {
870+
clipPath = 'M-' + (HOVERARROWSIZE + HOVERTEXTPAD) + '-' + halfHeight +
871+
'h-' + (tbb.width - HOVERTEXTPAD) +
872+
'V' + halfHeight +
873+
'h' + (tbb.width - HOVERTEXTPAD) + 'Z';
874+
875+
var ltx = tbb.width - lx + HOVERTEXTPAD;
876+
svgTextUtils.positionText(ltext, ltx, lty);
877+
878+
// shift each line (except the longest) so that start-of-line
879+
// is always visible
880+
if(anchor === 'end') {
881+
ltext.selectAll('tspan').each(function() {
882+
var s = d3.select(this);
883+
var dummy = Drawing.tester.append('text')
884+
.text(s.text())
885+
.call(Drawing.font, commonLabelFont);
886+
var dummyBB = dummy.node().getBoundingClientRect();
887+
if(dummyBB.width < tbb.width) {
888+
s.attr('x', ltx - dummyBB.width);
889+
}
890+
dummy.remove();
891+
});
892+
}
893+
} else {
894+
svgTextUtils.positionText(ltext, sgn * (HOVERTEXTPAD + HOVERARROWSIZE), lty);
895+
clipPath = null;
896+
}
897+
898+
var textClip = fullLayout._topclips.selectAll('#' + clipId).data(clipPath ? [0] : []);
899+
textClip.enter().append('clipPath').attr('id', clipId).append('path');
900+
textClip.exit().remove();
901+
textClip.select('path').attr('d', clipPath);
902+
Drawing.setClipUrl(ltext, clipPath ? clipId : null, gd);
827903
}
904+
905+
label.attr('transform', 'translate(' + lx + ',' + ly + ')');
906+
828907
// remove the "close but not quite" points
829908
// because of error bars, only take up to a space
830909
hoverData = hoverData.filter(function(d) {

test/jasmine/tests/hover_label_test.js

+119
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ var d3 = require('d3');
33
var Plotly = require('@lib/index');
44
var Fx = require('@src/components/fx');
55
var Lib = require('@src/lib');
6+
var Drawing = require('@src/components/drawing');
7+
68
var HOVERMINTIME = require('@src/components/fx').constants.HOVERMINTIME;
79
var MINUS_SIGN = require('@src/constants/numerical').MINUS_SIGN;
810

@@ -14,6 +16,7 @@ var delay = require('../assets/delay');
1416
var doubleClick = require('../assets/double_click');
1517
var failTest = require('../assets/fail_test');
1618
var touchEvent = require('../assets/touch_event');
19+
var negateIf = require('../assets/negate_if');
1720

1821
var customAssertions = require('../assets/custom_assertions');
1922
var assertHoverLabelStyle = customAssertions.assertHoverLabelStyle;
@@ -1700,6 +1703,122 @@ describe('hover info', function() {
17001703
});
17011704
});
17021705

1706+
describe('constraints info graph viewport', function() {
1707+
var gd;
1708+
1709+
beforeEach(function() { gd = createGraphDiv(); });
1710+
1711+
it('hovermode:x common label should fit in the graph div width', function(done) {
1712+
function _assert(msg, exp) {
1713+
return function() {
1714+
var label = d3.select('g.axistext');
1715+
if(label.node()) {
1716+
expect(label.text()).toBe(exp.txt, 'common label text| ' + msg);
1717+
expect(Drawing.getTranslate(label).x)
1718+
.toBeWithin(exp.lx, 5, 'common label translate-x| ' + msg);
1719+
1720+
var startOfPath = label.select('path').attr('d').split('L')[0];
1721+
expect(startOfPath).not.toBe('M0,0', 'offset start of label path| ' + msg);
1722+
} else {
1723+
fail('fail to generate common hover label');
1724+
}
1725+
};
1726+
}
1727+
1728+
function _hoverLeft() { return _hover(gd, 30, 300); }
1729+
1730+
function _hoverRight() { return _hover(gd, 370, 300); }
1731+
1732+
Plotly.plot(gd, [{
1733+
type: 'bar',
1734+
x: ['2019-01-01', '2019-06-01', '2020-01-01'],
1735+
y: [2, 5, 10]
1736+
}], {
1737+
xaxis: {range: ['2019-02-06', '2019-12-01']},
1738+
margin: {l: 0, r: 0},
1739+
width: 400,
1740+
height: 400
1741+
})
1742+
.then(_hoverLeft)
1743+
.then(_assert('left-edge hover', {txt: 'Jan 1, 2019', lx: 37}))
1744+
.then(_hoverRight)
1745+
.then(_assert('right-edge hover', {txt: 'Jan 1, 2020', lx: 362}))
1746+
.then(function() { return Plotly.relayout(gd, 'xaxis.side', 'top'); })
1747+
.then(_hoverLeft)
1748+
.then(_assert('left-edge hover (side:top)', {txt: 'Jan 1, 2019', lx: 37}))
1749+
.then(_hoverRight)
1750+
.then(_assert('right-edge hover (side:top)', {txt: 'Jan 1, 2020', lx: 362}))
1751+
.catch(failTest)
1752+
.then(done);
1753+
});
1754+
1755+
it('hovermode:y common label should shift and clip text start into graph div', function(done) {
1756+
function _assert(msg, exp) {
1757+
return function() {
1758+
var label = d3.select('g.axistext');
1759+
if(label.node()) {
1760+
var ltext = label.select('text');
1761+
expect(ltext.text()).toBe(exp.txt, 'common label text| ' + msg);
1762+
expect(ltext.attr('x')).toBeWithin(exp.ltx, 5, 'common label text x| ' + msg);
1763+
1764+
negateIf(exp.clip, expect(ltext.attr('clip-path'))).toBe(null, 'text clip url| ' + msg);
1765+
1766+
var fullLayout = gd._fullLayout;
1767+
var clipId = 'clip' + fullLayout._uid + 'commonlabely';
1768+
var clipPath = d3.select('#' + clipId);
1769+
negateIf(exp.clip, expect(clipPath.node())).toBe(null, 'text clip path|' + msg);
1770+
1771+
if(exp.tspanX) {
1772+
var tspans = label.selectAll('tspan');
1773+
if(tspans.size()) {
1774+
tspans.each(function(d, i) {
1775+
var s = d3.select(this);
1776+
expect(s.attr('x')).toBeWithin(exp.tspanX[i], 5, i + '- tspan shift| ' + msg);
1777+
});
1778+
} else {
1779+
fail('fail to generate tspans in hover label');
1780+
}
1781+
}
1782+
} else {
1783+
fail('fail to generate common hover label');
1784+
}
1785+
};
1786+
}
1787+
1788+
function _hoverWayLong() { return _hover(gd, 135, 100); }
1789+
1790+
function _hoverA() { return _hover(gd, 135, 20); }
1791+
1792+
Plotly.plot(gd, [{
1793+
type: 'bar',
1794+
orientation: 'h',
1795+
y: ['Looong label', 'Loooooger label', 'Waay loooong label', 'a'],
1796+
x: [2, 5, 10, 2]
1797+
}], {
1798+
width: 400,
1799+
height: 400
1800+
})
1801+
.then(_hoverWayLong)
1802+
.then(_assert('on way long label', {txt: 'Waay loooong label', clip: true, ltx: 38}))
1803+
.then(_hoverA)
1804+
.then(_assert('on "a" label', {txt: 'a', clip: false, ltx: -9}))
1805+
.then(function() {
1806+
return Plotly.restyle(gd, {
1807+
y: [['Looong label', 'Loooooger label', 'SHORT!<br>Waay loooong label', 'a']]
1808+
});
1809+
})
1810+
.then(_hoverWayLong)
1811+
.then(_assert('on way long label (multi-line case)', {
1812+
txt: 'SHORT!Waay loooong label',
1813+
clip: true,
1814+
ltx: 38,
1815+
tspanX: [-11, 38]
1816+
}))
1817+
.catch(failTest)
1818+
.then(done);
1819+
});
1820+
});
1821+
17031822
describe('hovertemplate', function() {
17041823
var mockCopy;
17051824

0 commit comments

Comments
 (0)