diff --git a/draftlogs/8138_fix.md b/draftlogs/8138_fix.md new file mode 100644 index 00000000000..4892486998e --- /dev/null +++ b/draftlogs/8138_fix.md @@ -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)] diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 342676cf654..41461d7b37a 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -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'] }); } diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index 21a0aa17beb..5ae89af6d16 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -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 + * @param {d3 selection} g: the 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 node's x/y * and text-anchor. @@ -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; @@ -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); }; /** diff --git a/test/jasmine/bundle_tests/mathjax_test.js b/test/jasmine/bundle_tests/mathjax_test.js index 75c1c888d32..9bdd1dafe5f 100644 --- a/test/jasmine/bundle_tests/mathjax_test.js +++ b/test/jasmine/bundle_tests/mathjax_test.js @@ -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 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' + longTex + '' + }], + 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]}],