Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions draftlogs/8138_fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- Fix hover labels that are a single tex expression being truncated by `hoverlabel.namelength` [[#8138](https://github.com/plotly/plotly.js/pull/8138)]
- Fix tex in hover labels being misplaced when labels are rotated in `hovermode: 'y'` [[#8138](https://github.com/plotly/plotly.js/pull/8138)]
6 changes: 4 additions & 2 deletions src/components/fx/hover.js
Original file line number Diff line number Diff line change
Expand Up @@ -2650,8 +2650,10 @@ function spikesChanged(gd, oldspikepoints) {
}

function plainText(s, len) {
return svgTextUtils.plainText(s || '', {
len: len,
s = s || '';
return svgTextUtils.plainText(s, {
// truncating a tex expression drops its closing $, so it would no longer be typeset
len: svgTextUtils.isPureTex(s) ? undefined : len,
allowedTags: ['br', 'sub', 'sup', 'b', 'i', 'em', 's', 'u']
});
}
Expand Down
33 changes: 30 additions & 3 deletions src/lib/svg_text_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,30 @@ const isPureTex = (str) => {
};
exports.isPureTex = isPureTex;

/**
* Size of the typeset glyphs in their own coordinate system when an ancestor
* is rotated (e.g. a hover label in hovermode 'y'), where getBoundingClientRect
* would report the larger screen-space box of the rotated shape.
*
* @param {d3 selection} newSvg: the typeset <svg>
* @param {d3 selection} g: the <g> inside newSvg that holds the glyphs
* @return {?{width: number, height: number}} null if no ancestor is rotated
*/
function getUnrotatedSize(newSvg, g) {
var svgNode = newSvg.node();
var ctm = svgNode.getScreenCTM();
if(!ctm || (Math.abs(ctm.b) < 1e-6 && Math.abs(ctm.c) < 1e-6)) return null;

var bbox = g.node().getBBox();
var viewBox = svgNode.viewBox.baseVal;
// the glyphs are letterboxed, so they scale by the smaller of the two ratios
var scale = Math.min(
svgNode.width.baseVal.value / viewBox.width,
svgNode.height.baseVal.value / viewBox.height
);
return {width: bbox.width * scale, height: bbox.height * scale};
}

/**
* Positions a MathJax-typeset group against its source <text> node's x/y
* and text-anchor.
Expand All @@ -50,10 +74,13 @@ exports.isPureTex = isPureTex;
* @param {number} [width0]: the SVG's own reported width, to detect the
* Firefox v82+ overflow bug (see link below); omit to skip that check
* @param {number} [height0]: same, for height
* @param {boolean} [unrotated]: measure in the group's own coordinates when an
* ancestor is rotated. Off by default so convertToTspans' other callers keep
* the screen-space measurement they were laid out with
*/
function positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize, width0, height0) {
function positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize, width0, height0, unrotated) {
var g = newSvg.select('g');
var bb = g.node().getBoundingClientRect();
var bb = (unrotated && getUnrotatedSize(newSvg, g)) || g.node().getBoundingClientRect();
var w = bb.width;
var h = bb.height;
var w0 = width0 === undefined ? w : width0;
Expand Down Expand Up @@ -260,7 +287,7 @@ exports.repositionMathGroup = function(_context) {

var textAnchor = _context.attr('text-anchor');
var fontSize = parseInt(_context.node().style.fontSize, 10);
positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize);
positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize, undefined, undefined, true);
};

/**
Expand Down
141 changes: 141 additions & 0 deletions test/jasmine/bundle_tests/mathjax_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,147 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() {
.then(done, done.fail);
});

it('should not truncate a pure-tex trace name to hoverlabel.namelength', function(done) {
var longTex = '$\\alpha_{1c} = 352 \\pm 11 \\text{ km s}^{-1}$';

Plotly.newPlot(gd, {
data: [{
type: 'scatter',
mode: 'markers',
x: [1, 2, 3],
y: [1, 2, 3],
name: longTex,
hoverinfo: 'x+y+name'
}],
layout: {
width: 500,
height: 400,
margin: {l: 0, t: 0, r: 0, b: 0},
xaxis: {range: [0, 4]},
yaxis: {range: [0, 4]}
}
})
.then(function() {
_hover(125, 300);
return delay(30)();
})
.then(function() {
var mathGroup = d3Select(gd).select('g.hovertext .name-math-group');

expect(mathGroup.size()).toBe(1, 'name math group');
expect(mathGroup.attr('data-unformatted')).toBe(longTex, 'full, untruncated tex');
})
.then(done, done.fail);
});

it('should not truncate a pure-tex hovertemplate <extra> name to hoverlabel.namelength', function(done) {
var longTex = '$\\alpha_{1c} = 352 \\pm 11 \\text{ km s}^{-1}$';

Plotly.newPlot(gd, {
data: [{
type: 'scatter',
mode: 'markers',
x: [1, 2, 3],
y: [1, 2, 3],
hovertemplate: 'v<extra>' + longTex + '</extra>'
}],
layout: {
width: 500,
height: 400,
margin: {l: 0, t: 0, r: 0, b: 0},
xaxis: {range: [0, 4]},
yaxis: {range: [0, 4]}
}
})
.then(function() {
_hover(125, 300);
return delay(30)();
})
.then(function() {
var mathGroup = d3Select(gd).select('g.hovertext .name-math-group');

expect(mathGroup.size()).toBe(1, 'name math group');
expect(mathGroup.attr('data-unformatted')).toBe(longTex, 'full, untruncated tex');
})
.then(done, done.fail);
});

it('should still truncate a plain-text trace name to hoverlabel.namelength', function(done) {
Plotly.newPlot(gd, {
data: [{
type: 'scatter',
mode: 'markers',
x: [1, 2, 3],
y: [1, 2, 3],
name: 'a rather long trace name',
hoverinfo: 'x+y+name'
}],
layout: {
width: 500,
height: 400,
margin: {l: 0, t: 0, r: 0, b: 0},
xaxis: {range: [0, 4]},
yaxis: {range: [0, 4]}
}
})
.then(function() {
_hover(125, 300);
return delay(30)();
})
.then(function() {
var nameText = d3Select(gd).select('g.hovertext text.name').text();

expect(nameText.length).toBeLessThan('a rather long trace name'.length, 'truncated');
expect(nameText).toMatch(/\.\.\.$/, 'ellipsis');
})
.then(done, done.fail);
});

it('should keep the tex name inside its box for rotated labels in hovermode y', function(done) {
Plotly.newPlot(gd, {
data: [
{type: 'scatter', mode: 'markers', x: [1], y: [1], name: '$\\alpha_{1c} = 352 \\pm 11 \\text{ km s}^{-1}$', hoverinfo: 'x+y+name'},
{type: 'scatter', mode: 'markers', x: [2], y: [1], name: '$\\gamma^2$', hoverinfo: 'x+y+name'}
],
layout: {
width: 500,
height: 400,
margin: {l: 0, t: 0, r: 0, b: 0},
xaxis: {range: [0, 4]},
yaxis: {range: [0, 4]},
hoverlabel: {namelength: -1},
hovermode: 'y'
}
})
.then(function() {
_hover(125, 300);
return delay(30)();
})
.then(function() {
var hoverTexts = d3Select(gd).selectAll('g.hovertext');

expect(hoverTexts.size()).toBe(2, 'two hover labels');
expect(hoverTexts.attr('transform')).toContain('rotate', 'labels are rotated');

hoverTexts.each(function() {
var g = d3Select(this);
var mathGroup = g.select('.name-math-group');
var box = g.select('rect');

expect(mathGroup.size()).toBe(1, 'name math group');
expect(box.size()).toBe(1, 'name box');

var m = mathGroup.node().getBoundingClientRect();
var b = box.node().getBoundingClientRect();
var dx = (m.left + m.right) / 2 - (b.left + b.right) / 2;
var dy = (m.top + m.bottom) / 2 - (b.top + b.bottom) / 2;

expect(Math.sqrt(dx * dx + dy * dy)).toBeLessThan(4, 'tex centered in its name box');
});
})
.then(done, done.fail);
});

it('should hand a pure-tex Fx.loneHover label off to MathJax, sized to its final content', function(done) {
Plotly.newPlot(gd, {
data: [{type: 'scatter', mode: 'markers', x: [1], y: [1]}],
Expand Down
Loading