Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Avoid overlap of point and axis hover labels #6442

Merged
merged 16 commits into from
Mar 6, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 141 additions & 24 deletions src/components/fx/hover.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,14 +209,15 @@ exports.loneHover = function loneHover(hoverItems, opts) {

var rotateLabels = false;

var hoverLabel = createHoverText(pointsData, {
var hoverText = createHoverText(pointsData, {
gd: gd,
hovermode: 'closest',
rotateLabels: rotateLabels,
bgColor: opts.bgColor || Color.background,
container: d3.select(opts.container),
outerContainer: opts.outerContainer || opts.container
});
var hoverLabel = hoverText.hoverLabels;

// Fix vertical overlap
var tooltipSpacing = 5;
Expand Down Expand Up @@ -819,7 +820,7 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
fullLayout.paper_bgcolor
);

var hoverLabels = createHoverText(hoverData, {
var hoverText = createHoverText(hoverData, {
gd: gd,
hovermode: hovermode,
rotateLabels: rotateLabels,
Expand All @@ -829,9 +830,10 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
commonLabelOpts: fullLayout.hoverlabel,
hoverdistance: fullLayout.hoverdistance
});
var hoverLabels = hoverText.hoverLabels;

if(!helpers.isUnifiedHover(hovermode)) {
hoverAvoidOverlaps(hoverLabels, rotateLabels ? 'xa' : 'ya', fullLayout);
hoverAvoidOverlaps(hoverLabels, rotateLabels, fullLayout, hoverText.commonLabel);
alignHoverText(hoverLabels, rotateLabels, fullLayout._invScaleX, fullLayout._invScaleY);
} // 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.
Expand Down Expand Up @@ -942,6 +944,7 @@ function createHoverText(hoverData, opts) {
.classed('axistext', true);
commonLabel.exit().remove();

var commonLabelLx, commonLabelLy;
commonLabel.each(function() {
var label = d3.select(this);
var lpath = Lib.ensureSingle(label, 'path', '', function(s) {
Expand Down Expand Up @@ -1087,6 +1090,9 @@ function createHoverText(hoverData, opts) {
}

label.attr('transform', strTranslate(lx, ly));

commonLabelLx = lx;
commonLabelLy = ly;
});

// Show a single hover label
Expand Down Expand Up @@ -1370,7 +1376,10 @@ function createHoverText(hoverData, opts) {
} else if(anchorStartOK) {
hty += dy / 2;
d.anchor = 'start';
} else d.anchor = 'middle';
} else {
d.anchor = 'middle';
}
d.crossPos = hty;
} else {
d.pos = hty;
anchorStartOK = htx + dx / 2 + txTotalWidth <= outerWidth;
Expand All @@ -1391,6 +1400,7 @@ function createHoverText(hoverData, opts) {
if(overflowR > 0) htx -= overflowR;
if(overflowL < 0) htx += -overflowL;
}
d.crossPos = htx;
}

tx.attr('text-anchor', d.anchor);
Expand All @@ -1399,7 +1409,14 @@ function createHoverText(hoverData, opts) {
(rotateLabels ? strRotate(YANGLE) : ''));
});

return hoverLabels;
return {
hoverLabels: hoverLabels,
commonLabel: {
lx: commonLabelLx,
ly: commonLabelLy,
label: commonLabel
}
};
}

function getHoverLabelText(d, showCommonLabel, hovermode, fullLayout, t0, g) {
Expand Down Expand Up @@ -1493,7 +1510,9 @@ function getHoverLabelText(d, showCommonLabel, hovermode, fullLayout, t0, g) {
// know what happens if the group spans all the way from one edge to
// the other, though it hardly matters - there's just too much
// information then.
function hoverAvoidOverlaps(hoverLabels, axKey, fullLayout) {
function hoverAvoidOverlaps(hoverLabels, rotateLabels, fullLayout, commonLabel) {
var axKey = rotateLabels ? 'xa' : 'ya';
var crossAxKey = rotateLabels ? 'ya' : 'xa';
var nummoves = 0;
var axSign = 1;
var nLabels = hoverLabels.size();
Expand All @@ -1502,23 +1521,94 @@ function hoverAvoidOverlaps(hoverLabels, axKey, fullLayout) {
var pointgroups = new Array(nLabels);
var k = 0;

// get extent of axis hover label
var axisLabelMinX, axisLabelMaxX, axisLabelMinY, axisLabelMaxY;
if(commonLabel) {
commonLabel.label.each(function() {
var selection = d3.select(this);
if(selection && selection.length && selection[0] && selection[0].length && selection[0][0]) {
var bbox = selection[0][0].getBBox();
if(bbox) {
axisLabelMinX = commonLabel.lx;
axisLabelMaxX = commonLabel.lx + bbox.width;
axisLabelMinY = commonLabel.ly;
axisLabelMaxY = commonLabel.ly + bbox.height;
}
}
});
}

var pX = function(x) { return x * fullLayout._invScaleX; };
var pY = function(y) { return y * fullLayout._invScaleY; };

hoverLabels.each(function(d) {
var ax = d[axKey];
var crossAx = d[crossAxKey];
var axIsX = ax._id.charAt(0) === 'x';
var rng = ax.range;

if(k === 0 && rng && ((rng[0] > rng[1]) !== axIsX)) {
axSign = -1;
}
var pmin = 0;
var pmax = (axIsX ? fullLayout.width : fullLayout.height);
// in hovermode avoid overlap between hover labels and axis label
if(fullLayout.hovermode === 'x' || fullLayout.hovermode === 'y') {
// extent of rect behind hover label on cross axis:
var offsets = getHoverLabelOffsets(d, rotateLabels);
var anchor = d.anchor;
var horzSign = anchor === 'end' ? -1 : 1;
var labelMin;
var labelMax;
if(anchor === 'middle') {
// use extent of centered rect either on x or y axis depending on current axis
labelMin = d.crossPos + (axIsX ? pY(offsets.y - d.by / 2) : pX(d.bx / 2 + d.tx2width / 2));
labelMax = labelMin + (axIsX ? pY(d.by) : pX(d.bx));
} else {
// use extend of path (see alignHoverText function) without arrow
if(axIsX) {
labelMin = d.crossPos + pY(HOVERARROWSIZE + offsets.y) - pY(d.by / 2 - HOVERARROWSIZE);
labelMax = labelMin + pY(d.by);
} else {
var startX = pX(horzSign * HOVERARROWSIZE + offsets.x);
var endX = startX + pX(horzSign * d.bx);
labelMin = d.crossPos + Math.min(startX, endX);
labelMax = d.crossPos + Math.max(startX, endX);
}
}

if(axIsX) {
if(axisLabelMinY !== undefined && axisLabelMaxY !== undefined && Math.min(labelMax, axisLabelMaxY) - Math.max(labelMin, axisLabelMinY) > 1) {
// has at least 1 pixel overlap with axis label
if(crossAx.side === 'left') {
pmin = crossAx._mainLinePosition;
pmax = fullLayout.width;
} else {
pmax = crossAx._mainLinePosition;
}
}
} else {
if(axisLabelMinX !== undefined && axisLabelMaxX !== undefined && Math.min(labelMax, axisLabelMaxX) - Math.max(labelMin, axisLabelMinX) > 1) {
// has at least 1 pixel overlap with axis label
if(crossAx.side === 'top') {
pmin = crossAx._mainLinePosition;
pmax = fullLayout.height;
} else {
pmax = crossAx._mainLinePosition;
}
}
}
}

pointgroups[k++] = [{
datum: d,
traceIndex: d.trace.index,
dp: 0,
pos: d.pos,
posref: d.posref,
size: d.by * (axIsX ? YFACTOR : 1) / 2,
pmin: 0,
pmax: (axIsX ? fullLayout.width : fullLayout.height)
pmin: pmin,
pmax: pmax
}];
});

Expand Down Expand Up @@ -1662,6 +1752,42 @@ function hoverAvoidOverlaps(hoverLabels, axKey, fullLayout) {
}
}

function getHoverLabelOffsets(hoverLabel, rotateLabels) {
var offsetX = 0;
var offsetY = hoverLabel.offset;

if(rotateLabels) {
offsetY *= -YSHIFTY;
offsetX = hoverLabel.offset * YSHIFTX;
}

return {
x: offsetX,
y: offsetY
};
}

/**
* Calculate the shift in x for text and text2 elements
*/
function getTextShiftX(hoverLabel) {
var alignShift = {start: 1, end: -1, middle: 0}[hoverLabel.anchor];
var textShiftX = alignShift * (HOVERARROWSIZE + HOVERTEXTPAD);
var text2ShiftX = textShiftX + alignShift * (hoverLabel.txwidth + HOVERTEXTPAD);

var isMiddle = hoverLabel.anchor === 'middle';
if(isMiddle) {
textShiftX -= hoverLabel.tx2width / 2;
text2ShiftX += hoverLabel.txwidth / 2 + HOVERTEXTPAD;
}

return {
alignShift: alignShift,
textShiftX: textShiftX,
text2ShiftX: text2ShiftX
};
}

function alignHoverText(hoverLabels, rotateLabels, scaleX, scaleY) {
var pX = function(x) { return x * scaleX; };
var pY = function(y) { return y * scaleY; };
Expand All @@ -1675,21 +1801,12 @@ function alignHoverText(hoverLabels, rotateLabels, scaleX, scaleY) {
var tx = g.select('text.nums');
var anchor = d.anchor;
var horzSign = anchor === 'end' ? -1 : 1;
var alignShift = {start: 1, end: -1, middle: 0}[anchor];
var txx = alignShift * (HOVERARROWSIZE + HOVERTEXTPAD);
var tx2x = txx + alignShift * (d.txwidth + HOVERTEXTPAD);
var offsetX = 0;
var offsetY = d.offset;
var shiftX = getTextShiftX(d);
var offsets = getHoverLabelOffsets(d, rotateLabels);
var offsetX = offsets.x;
var offsetY = offsets.y;

var isMiddle = anchor === 'middle';
if(isMiddle) {
txx -= d.tx2width / 2;
tx2x += d.txwidth / 2 + HOVERTEXTPAD;
}
if(rotateLabels) {
offsetY *= -YSHIFTY;
offsetX = d.offset * YSHIFTX;
}

g.select('path')
.attr('d', isMiddle ?
Expand All @@ -1705,7 +1822,7 @@ function alignHoverText(hoverLabels, rotateLabels, scaleX, scaleY) {
'V' + pY(offsetY - HOVERARROWSIZE) +
'Z'));

var posX = offsetX + txx;
var posX = offsetX + shiftX.textShiftX;
var posY = offsetY + d.ty0 - d.by / 2 + HOVERTEXTPAD;
var textAlign = d.textAlign || 'auto';

Expand All @@ -1728,11 +1845,11 @@ function alignHoverText(hoverLabels, rotateLabels, scaleX, scaleY) {
if(d.tx2width) {
g.select('text.name')
.call(svgTextUtils.positionText,
pX(tx2x + alignShift * HOVERTEXTPAD + offsetX),
pX(shiftX.text2ShiftX + shiftX.alignShift * HOVERTEXTPAD + offsetX),
pY(offsetY + d.ty0 - d.by / 2 + HOVERTEXTPAD));
g.select('rect')
.call(Drawing.setRect,
pX(tx2x + (alignShift - 1) * d.tx2width / 2 + offsetX),
pX(shiftX.text2ShiftX + (shiftX.alignShift - 1) * d.tx2width / 2 + offsetX),
pY(offsetY - d.by / 2 - 1),
pX(d.tx2width), pY(d.by + 2));
}
Expand Down
77 changes: 73 additions & 4 deletions test/jasmine/tests/hover_label_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1513,7 +1513,7 @@ describe('hover info', function() {

describe('overflowing hover labels', function() {
var trace = {y: [1, 2, 3], text: ['', 'a<br>b<br>c', '']};
var data = [trace, trace, trace, trace, trace, trace, trace];
var data = [trace, trace, trace, trace, trace, trace, trace, trace, trace, trace];
var layout = {
width: 600, height: 600, showlegend: false,
margin: {l: 100, r: 100, t: 100, b: 100},
Expand All @@ -1531,17 +1531,86 @@ describe('hover info', function() {
return d3Select(gd).selectAll('g.hovertext').size();
}

it('shows as many labels as will fit on the div, not on the subplot', function(done) {
it('shows as many labels as will fit on the div, not on the subplot, when labels do not overlap the axis label', function(done) {
_hoverNatural(gd, 200, 200);

expect(labelCount()).toBe(7);
expect(labelCount()).toBe(8);

Plotly.relayout(gd, {'yaxis.domain': [0.48, 0.52]})
.then(function() {
_hoverNatural(gd, 150, 200);
_hoverNatural(gd, 200, 200);

expect(labelCount()).toBe(7);
expect(labelCount()).toBe(8);
})
.then(done, done.fail);
});
});

describe('overlapping hover labels', function() {
var trace = {y: [1, 2, 3], x: ['01.01.2020', '02.01.2020', '03.01.2020'], text: ['', 'a<br>b<br>c', '']};
var data = [trace, trace, trace, trace, trace, trace, trace, trace, trace, trace];
var layout = {
width: 600, height: 600, showlegend: false,
margin: {l: 100, r: 100, t: 100, b: 100},
hovermode: 'x'
};

var gd;

beforeEach(function(done) {
gd = createGraphDiv();
Plotly.newPlot(gd, data, layout).then(done);
});

function labelCount() {
return d3Select(gd).selectAll('g.hovertext').size();
}

it('does not show labels that would overlap the axis hover label', function(done) {
_hoverNatural(gd, 200, 200);

expect(labelCount()).toBe(6);

Plotly.relayout(gd, {'yaxis.domain': [0.48, 0.52]})
.then(function() {
_hoverNatural(gd, 150, 200);
_hoverNatural(gd, 200, 200);

expect(labelCount()).toBe(4);
})
.then(done, done.fail);
});
});
describe('overlapping hover labels of different lengths', function() {
var data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20].map(function(v) {return {x: [100, 200, 300], y: [v, v + 1, v + 2]};});
var layout = {
width: 500, height: 400, showlegend: false,
margin: {l: 100, r: 100, t: 100, b: 100},
hovermode: 'x'
};

var gd;

beforeEach(function(done) {
gd = createGraphDiv();
Plotly.newPlot(gd, data, layout).then(done);
});

function labelCount() {
return d3Select(gd).selectAll('g.hovertext').size();
}

it('does not show labels that would overlap the axis hover label', function(done) {
_hoverNatural(gd, 130, 100);

expect(labelCount()).toBe(14);

Plotly.relayout(gd, {'yaxis.domain': [0.2, 0.8]})
.then(function() {
_hoverNatural(gd, 130, 100);

expect(labelCount()).toBe(12);
})
.then(done, done.fail);
});
Expand Down