From 0a1c4c880e71646a9ffbe8424dde89fd5e6d32a7 Mon Sep 17 00:00:00 2001 From: Sergey Slizovskiy Date: Wed, 21 Oct 2026 00:35:34 +0200 Subject: [PATCH 1/3] Fix tex hover label name truncation and rotated label placement Two follow-ups to #8051, reported on that PR: - A trace name that is a single tex expression was cut to hoverlabel.namelength (default 15) before the pure-tex check, which dropped the closing $ so it was never typeset. Skip truncation for pure-tex names. - In hovermode 'y' with several traces the labels are rotated, and positionMathGroup measured the glyphs with getBoundingClientRect, which is the larger screen-space box of the rotated shape, so the math landed far from its label. Measure in the group's own coordinates when an ancestor is rotated. The draftlog file name carries a placeholder PR number to be renamed once the PR is opened. Co-Authored-By: Claude Sonnet 5.5 --- draftlogs/0000_fix.md | 2 + src/components/fx/hover.js | 6 +- src/lib/svg_text_utils.js | 26 +++- test/jasmine/bundle_tests/mathjax_test.js | 141 ++++++++++++++++++++++ 4 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 draftlogs/0000_fix.md diff --git a/draftlogs/0000_fix.md b/draftlogs/0000_fix.md new file mode 100644 index 00000000000..83b91441931 --- /dev/null +++ b/draftlogs/0000_fix.md @@ -0,0 +1,2 @@ +- Fix hover labels that are a single tex expression being truncated by `hoverlabel.namelength` [[#0000](https://github.com/plotly/plotly.js/pull/0000)] +- Fix tex in hover labels being misplaced when labels are rotated in `hovermode: 'y'` [[#0000](https://github.com/plotly/plotly.js/pull/0000)] 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..db50cf58219 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. @@ -53,7 +77,7 @@ exports.isPureTex = isPureTex; */ function positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize, width0, height0) { var g = newSvg.select('g'); - var bb = g.node().getBoundingClientRect(); + var bb = getUnrotatedSize(newSvg, g) || g.node().getBoundingClientRect(); var w = bb.width; var h = bb.height; var w0 = width0 === undefined ? w : width0; 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]}], From fc4da9654a08c35ef982fb86ee9cb5a8a9f08c34 Mon Sep 17 00:00:00 2001 From: Sergey Slizovskiy Date: Wed, 21 Oct 2026 08:42:04 +0200 Subject: [PATCH 2/3] Rename draftlog to the PR number Co-Authored-By: Claude Sonnet 5.5 --- draftlogs/0000_fix.md | 2 -- draftlogs/8138_fix.md | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 draftlogs/0000_fix.md create mode 100644 draftlogs/8138_fix.md diff --git a/draftlogs/0000_fix.md b/draftlogs/0000_fix.md deleted file mode 100644 index 83b91441931..00000000000 --- a/draftlogs/0000_fix.md +++ /dev/null @@ -1,2 +0,0 @@ -- Fix hover labels that are a single tex expression being truncated by `hoverlabel.namelength` [[#0000](https://github.com/plotly/plotly.js/pull/0000)] -- Fix tex in hover labels being misplaced when labels are rotated in `hovermode: 'y'` [[#0000](https://github.com/plotly/plotly.js/pull/0000)] 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)] From 2c24c2907d3d88e6edbc6ab02d16d37a53ff7fca Mon Sep 17 00:00:00 2001 From: Sergey Slizovskiy Date: Wed, 21 Oct 2026 08:55:30 +0200 Subject: [PATCH 3/3] Limit rotation-safe tex measurement to repositionMathGroup Measuring the typeset glyphs in their own coordinates when an ancestor is rotated also moved the rotated ternary axis titles, which go through convertToTspans and failed the ternary-mathjax image baselines. Only repositionMathGroup (used by hover labels) opts in now; convertToTspans measures exactly as before. Co-Authored-By: Claude Sonnet 5.5 --- src/lib/svg_text_utils.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index db50cf58219..5ae89af6d16 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -74,10 +74,13 @@ function getUnrotatedSize(newSvg, g) { * @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 = getUnrotatedSize(newSvg, g) || 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; @@ -284,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); }; /**