Skip to content

Commit

Permalink
Merge pull request #4298 from plotly/common-label-overlaps
Browse files Browse the repository at this point in the history
Workarounds for "common" (aka axis) hover label clipping
  • Loading branch information
etpinard authored Oct 25, 2019
2 parents 3b6832e + 8d79522 commit c8a9f8a
Show file tree
Hide file tree
Showing 2 changed files with 222 additions and 24 deletions.
127 changes: 103 additions & 24 deletions src/components/fx/hover.js
Original file line number Diff line number Diff line change
Expand Up @@ -772,59 +772,138 @@ 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,
stroke: commonStroke
});

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) +
'h' + leftsign + (HOVERTEXTPAD * 2 + tbb.width) +
'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) {
Expand Down
119 changes: 119 additions & 0 deletions test/jasmine/tests/hover_label_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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!<br>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;

Expand Down

0 comments on commit c8a9f8a

Please sign in to comment.