From 0a1c4c880e71646a9ffbe8424dde89fd5e6d32a7 Mon Sep 17 00:00:00 2001 From: Sergey Slizovskiy Date: Wed, 21 Oct 2026 00:35:34 +0200 Subject: [PATCH 1/4] 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/4] 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/4] 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); }; /** From 86b0c3e11344a8c3d5901a1b9cb582f09baf5942 Mon Sep 17 00:00:00 2001 From: Sergey Slizovskiy Date: Mon, 26 Oct 2026 09:34:54 +0100 Subject: [PATCH 4/4] Measure tex hover labels by their glyphs, not the wrapping group In Firefox, getBoundingClientRect on the g.-math-group wrapper can report a near-empty box (2x0.2px for a 130x14px formula on a rotated hover label) while the glyph group inside it measures correctly. The hover label box was sized from that, so rotated tex names got a ~8px wide box. getMathOrTextNode now returns the glyph group; the rotated-label test measures the glyphs too. Reproduced and verified in Firefox 157 and Chrome. Co-Authored-By: Claude Sonnet 5.5 --- src/lib/svg_text_utils.js | 11 ++++++++--- test/jasmine/bundle_tests/mathjax_test.js | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index 5ae89af6d16..264e4609784 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -292,8 +292,8 @@ exports.repositionMathGroup = function(_context) { /** * Finds the node whose bounding box represents a source element's - * rendered content: the typeset MathJax group if convertToTspans replaced - * the text with one (the source itself is display:none in that case), or + * rendered content: the typeset glyphs if convertToTspans replaced the text + * with a MathJax group (the source itself is display:none in that case), or * the node otherwise. * * @param {d3 selection} textSel: the source element @@ -305,7 +305,12 @@ exports.getMathOrTextNode = function(textSel) { var svgClass = (textSel.attr('class') ? textSel.attr('class').split(' ')[0] : 'text') + '-math'; var mathGroup = d3.select(node.parentNode).select('g.' + svgClass + '-group'); - return mathGroup.empty() ? node : mathGroup.node(); + if(mathGroup.empty()) return node; + + // Firefox can report a near-empty getBoundingClientRect for the group + // itself (seen on rotated hover labels), but measures the glyphs correctly + var glyphs = mathGroup.select('svg g'); + return (glyphs.empty() ? mathGroup : glyphs).node(); }; diff --git a/test/jasmine/bundle_tests/mathjax_test.js b/test/jasmine/bundle_tests/mathjax_test.js index 9bdd1dafe5f..48f00fec6de 100644 --- a/test/jasmine/bundle_tests/mathjax_test.js +++ b/test/jasmine/bundle_tests/mathjax_test.js @@ -723,7 +723,7 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() { expect(mathGroup.size()).toBe(1, 'name math group'); expect(box.size()).toBe(1, 'name box'); - var m = mathGroup.node().getBoundingClientRect(); + var m = mathGroup.select('svg g').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;