Skip to content

Commit 406c21c

Browse files
Measure tex hover labels once and reuse the size
Review follow-up. The rotated-label fix detected rotation (getScreenCTM) and measured differently in repositionMathGroup than at first placement. Instead, hover now records the tex size measured in the neutral, unrotated state (where finalizePosition and positionCommonLabel already measure) and passes it back into repositionMathGroup, so a label is never re-measured while rotated. - positionMathGroup takes an explicit size; convertToTspans measures exactly as before, so titles, annotations and ternary are unchanged - repositionMathGroup accepts and returns a size; the unrotated flag and getUnrotatedSize are gone - getMathOrTextNode becomes getTextClientRect, so hover no longer handles MathJax's nodes; it still measures the glyphs, as Firefox can report a near-empty rect for the wrapping group - document that a tex name ignores hoverlabel.namelength, and regenerate the schema - add a test for a tex main label in rotated hovermode y Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 86b0c3e commit 406c21c

6 files changed

Lines changed: 160 additions & 115 deletions

File tree

‎src/components/fx/hover.js‎

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -267,10 +267,10 @@ exports.loneHover = function loneHover(hoverItems, opts) {
267267
fixVerticalOverlap();
268268
alignHoverText(hoverLabel, rotateLabels, scaleX, scaleY);
269269

270-
hoverLabel.each(function () {
270+
hoverLabel.each(function (d) {
271271
var g = d3.select(this);
272-
svgTextUtils.repositionMathGroup(g.select('text.nums'));
273-
svgTextUtils.repositionMathGroup(g.select('text.name'));
272+
svgTextUtils.repositionMathGroup(g.select('text.nums'), d.numsMathSize);
273+
svgTextUtils.repositionMathGroup(g.select('text.name'), d.nameMathSize);
274274
});
275275
});
276276
}
@@ -1053,10 +1053,10 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
10531053
hoverAvoidOverlaps(hoverLabels, rotateLabels, fullLayout, hoverText.commonLabelBoundingBox);
10541054
alignHoverText(hoverLabels, rotateLabels, fullLayout._invScaleX, fullLayout._invScaleY);
10551055

1056-
hoverLabels.each(function () {
1056+
hoverLabels.each(function (d) {
10571057
var g = d3.select(this);
1058-
svgTextUtils.repositionMathGroup(g.select('text.nums'));
1059-
svgTextUtils.repositionMathGroup(g.select('text.name'));
1058+
svgTextUtils.repositionMathGroup(g.select('text.nums'), d.numsMathSize);
1059+
svgTextUtils.repositionMathGroup(g.select('text.name'), d.nameMathSize);
10601060
});
10611061
});
10621062
}
@@ -1103,7 +1103,7 @@ function hoverDataKey(d) {
11031103
var EXTRA_STRING_REGEX = /<extra>([\s\S]*)<\/extra>/;
11041104

11051105
function getHoverTextBBox(gd, textSel) {
1106-
return getBoundingClientRect(gd, svgTextUtils.getMathOrTextNode(textSel));
1106+
return toLayoutRect(gd, svgTextUtils.getTextClientRect(textSel));
11071107
}
11081108

11091109
function createHoverText(hoverData, opts) {
@@ -1220,6 +1220,7 @@ function createHoverText(hoverData, opts) {
12201220
});
12211221

12221222
var commonLabelTex = svgTextUtils.isPureTex(String(t0));
1223+
var commonMathSize;
12231224
var onCommonLabelReady;
12241225

12251226
if (commonLabelTex) {
@@ -1231,7 +1232,7 @@ function createHoverText(hoverData, opts) {
12311232
return;
12321233
}
12331234
positionCommonLabel();
1234-
svgTextUtils.repositionMathGroup(ltext);
1235+
svgTextUtils.repositionMathGroup(ltext, commonMathSize);
12351236
resolve();
12361237
};
12371238
})
@@ -1250,7 +1251,7 @@ function createHoverText(hoverData, opts) {
12501251
function positionCommonLabel() {
12511252
label.attr('transform', '');
12521253
svgTextUtils.positionText(ltext, 0, 0);
1253-
svgTextUtils.repositionMathGroup(ltext);
1254+
commonMathSize = svgTextUtils.repositionMathGroup(ltext);
12541255

12551256
var tbb = getHoverTextBBox(gd, ltext);
12561257
var lx, ly;
@@ -1758,10 +1759,10 @@ function createHoverText(hoverData, opts) {
17581759
function finalizePosition() {
17591760
g.attr('transform', '');
17601761
tx.call(svgTextUtils.positionText, 0, 0);
1761-
svgTextUtils.repositionMathGroup(tx);
1762+
d.numsMathSize = svgTextUtils.repositionMathGroup(tx);
17621763
if (hasName) {
17631764
tx2.call(svgTextUtils.positionText, 0, 0);
1764-
svgTextUtils.repositionMathGroup(tx2);
1765+
d.nameMathSize = svgTextUtils.repositionMathGroup(tx2);
17651766
}
17661767

17671768
g.select('path').style({
@@ -2726,9 +2727,11 @@ const getTopOffset = (gd) => gd.offsetTop + gd.clientTop;
27262727
const getLeftOffset = (gd) => gd.offsetLeft + gd.clientLeft;
27272728

27282729
function getBoundingClientRect(gd, node) {
2729-
var fullLayout = gd._fullLayout;
2730+
return toLayoutRect(gd, node.getBoundingClientRect());
2731+
}
27302732

2731-
var rect = node.getBoundingClientRect();
2733+
function toLayoutRect(gd, rect) {
2734+
var fullLayout = gd._fullLayout;
27322735

27332736
var x0 = rect.left;
27342737
var y0 = rect.top;

‎src/components/fx/layout_attributes.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,8 @@ module.exports = {
202202
'regardless of length. 0-3 shows the first 0-3 characters, and',
203203
'an integer >3 will show the whole name if it is less than that',
204204
'many characters, but if it is longer, will truncate to',
205-
'`namelength - 3` characters and add an ellipsis.'
205+
'`namelength - 3` characters and add an ellipsis.',
206+
'A name that is a single tex expression is always shown in full.'
206207
].join(' ')
207208
},
208209
showarrow: {

‎src/lib/svg_text_utils.js‎

Lines changed: 47 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -38,49 +38,16 @@ const isPureTex = (str) => {
3838
exports.isPureTex = isPureTex;
3939

4040
/**
41-
* Size of the typeset glyphs in their own coordinate system when an ancestor
42-
* is rotated (e.g. a hover label in hovermode 'y'), where getBoundingClientRect
43-
* would report the larger screen-space box of the rotated shape.
41+
* Measures the typeset glyphs of a MathJax svg.
4442
*
4543
* @param {d3 selection} newSvg: the typeset <svg>
46-
* @param {d3 selection} g: the <g> inside newSvg that holds the glyphs
47-
* @return {?{width: number, height: number}} null if no ancestor is rotated
48-
*/
49-
function getUnrotatedSize(newSvg, g) {
50-
var svgNode = newSvg.node();
51-
var ctm = svgNode.getScreenCTM();
52-
if(!ctm || (Math.abs(ctm.b) < 1e-6 && Math.abs(ctm.c) < 1e-6)) return null;
53-
54-
var bbox = g.node().getBBox();
55-
var viewBox = svgNode.viewBox.baseVal;
56-
// the glyphs are letterboxed, so they scale by the smaller of the two ratios
57-
var scale = Math.min(
58-
svgNode.width.baseVal.value / viewBox.width,
59-
svgNode.height.baseVal.value / viewBox.height
60-
);
61-
return {width: bbox.width * scale, height: bbox.height * scale};
62-
}
63-
64-
/**
65-
* Positions a MathJax-typeset group against its source <text> node's x/y
66-
* and text-anchor.
67-
*
68-
* @param {d3 selection} _context: the source <text> element
69-
* @param {string} svgClass: the '<baseClass>-math' class used on newSvg
70-
* @param {d3 selection} mathjaxGroup: the '<svgClass>-group' wrapper
71-
* @param {d3 selection} newSvg: the typeset <svg>, already sized
72-
* @param {string} textAnchor: _context's text-anchor to position against
73-
* @param {number} fontSize: _context's font size, for the baseline offset
7444
* @param {number} [width0]: the SVG's own reported width, to detect the
7545
* Firefox v82+ overflow bug (see link below); omit to skip that check
7646
* @param {number} [height0]: same, for height
77-
* @param {boolean} [unrotated]: measure in the group's own coordinates when an
78-
* ancestor is rotated. Off by default so convertToTspans' other callers keep
79-
* the screen-space measurement they were laid out with
47+
* @return {{width: number, height: number}}
8048
*/
81-
function positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize, width0, height0, unrotated) {
82-
var g = newSvg.select('g');
83-
var bb = (unrotated && getUnrotatedSize(newSvg, g)) || g.node().getBoundingClientRect();
49+
function measureMath(newSvg, width0, height0) {
50+
var bb = newSvg.select('g').node().getBoundingClientRect();
8451
var w = bb.width;
8552
var h = bb.height;
8653
var w0 = width0 === undefined ? w : width0;
@@ -95,6 +62,25 @@ function positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor,
9562
h = bb.height;
9663
}
9764

65+
return {width: w, height: h};
66+
}
67+
68+
/**
69+
* Positions a MathJax-typeset group against its source <text> node's x/y
70+
* and text-anchor.
71+
*
72+
* @param {d3 selection} _context: the source <text> element
73+
* @param {string} svgClass: the '<baseClass>-math' class used on newSvg
74+
* @param {d3 selection} mathjaxGroup: the '<svgClass>-group' wrapper
75+
* @param {d3 selection} newSvg: the typeset <svg>, already sized
76+
* @param {string} textAnchor: _context's text-anchor to position against
77+
* @param {number} fontSize: _context's font size, for the baseline offset
78+
* @param {{width: number, height: number}} size: the typeset glyphs' size
79+
*/
80+
function positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize, size) {
81+
var w = size.width;
82+
var h = size.height;
83+
9884
var x = +_context.attr('x');
9985
var y = +_context.attr('y');
10086

@@ -258,7 +244,7 @@ exports.convertToTspans = function(_context, gd, _callback) {
258244
var g = newSvg.select('g');
259245
g.attr({fill: fill, stroke: fill});
260246

261-
positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize, w0, h0);
247+
positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize, measureMath(newSvg, w0, h0));
262248

263249
if(_callback) _callback.call(_context, mathjaxGroup);
264250
resolve(mathjaxGroup);
@@ -274,43 +260,52 @@ exports.convertToTspans = function(_context, gd, _callback) {
274260
* convertToTspans, to match its source <text> node's *current* x/y and
275261
* text-anchor.
276262
*
263+
* The size is measured from the page, so it changes with the rotation or
264+
* scale of whatever the group sits in. A caller that moves the text into such
265+
* a state should measure once beforehand and pass that size back in.
266+
*
277267
* @param {d3 selection} _context: the source <text> element
268+
* @param {{width: number, height: number}} [size]: a size returned by an
269+
* earlier call, to use instead of measuring again
270+
* @return {?{width: number, height: number}} the size used, or null if there
271+
* is no typeset group to reposition
278272
*/
279-
exports.repositionMathGroup = function(_context) {
280-
if(_context.empty()) return;
273+
exports.repositionMathGroup = function(_context, size) {
274+
if(_context.empty()) return null;
281275

282276
var svgClass = (_context.attr('class') ? _context.attr('class').split(' ')[0] : 'text') + '-math';
283277
var parent = d3.select(_context.node().parentNode);
284278
var mathjaxGroup = parent.select('g.' + svgClass + '-group');
285279
var newSvg = mathjaxGroup.select('svg.' + svgClass);
286-
if(mathjaxGroup.empty() || newSvg.empty()) return;
280+
if(mathjaxGroup.empty() || newSvg.empty()) return null;
287281

282+
size = size || measureMath(newSvg);
288283
var textAnchor = _context.attr('text-anchor');
289284
var fontSize = parseInt(_context.node().style.fontSize, 10);
290-
positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize, undefined, undefined, true);
285+
positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize, size);
286+
return size;
291287
};
292288

293289
/**
294-
* Finds the node whose bounding box represents a source <text> element's
295-
* rendered content: the typeset glyphs if convertToTspans replaced the text
296-
* with a MathJax group (the source itself is display:none in that case), or
297-
* the <text> node otherwise.
290+
* The client rect of a source <text> element's rendered content: the typeset
291+
* glyphs if convertToTspans replaced the text with a MathJax group (the source
292+
* itself is display:none in that case), or the <text> node otherwise.
298293
*
299294
* @param {d3 selection} textSel: the source <text> element
300-
* @return {?Element} the node to measure, or null if textSel is empty
295+
* @return {?DOMRect} null if textSel is empty
301296
*/
302-
exports.getMathOrTextNode = function(textSel) {
297+
exports.getTextClientRect = function(textSel) {
303298
var node = textSel.node();
304299
if(!node) return null;
305300

306301
var svgClass = (textSel.attr('class') ? textSel.attr('class').split(' ')[0] : 'text') + '-math';
307302
var mathGroup = d3.select(node.parentNode).select('g.' + svgClass + '-group');
308-
if(mathGroup.empty()) return node;
303+
if(mathGroup.empty()) return node.getBoundingClientRect();
309304

310-
// Firefox can report a near-empty getBoundingClientRect for the group
311-
// itself (seen on rotated hover labels), but measures the glyphs correctly
305+
// Firefox can report a near-empty rect for the group itself (seen on
306+
// rotated hover labels), but measures the glyphs inside it correctly
312307
var glyphs = mathGroup.select('svg g');
313-
return (glyphs.empty() ? mathGroup : glyphs).node();
308+
return (glyphs.empty() ? mathGroup : glyphs).node().getBoundingClientRect();
314309
};
315310

316311

‎src/types/generated/schema.d.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ export interface HoverLabel {
449449
/** Sets the font used in hover labels. */
450450
font?: FontArray;
451451
/**
452-
* Sets the default length (in number of characters) of the trace name in the hover labels for all traces. -1 shows the whole name regardless of length. 0-3 shows the first 0-3 characters, and an integer >3 will show the whole name if it is less than that many characters, but if it is longer, will truncate to `namelength - 3` characters and add an ellipsis.
452+
* Sets the default length (in number of characters) of the trace name in the hover labels for all traces. -1 shows the whole name regardless of length. 0-3 shows the first 0-3 characters, and an integer >3 will show the whole name if it is less than that many characters, but if it is longer, will truncate to `namelength - 3` characters and add an ellipsis. A name that is a single tex expression is always shown in full.
453453
* @default 15
454454
* Minimum: -1
455455
*/
@@ -1588,7 +1588,7 @@ export interface CandlestickData {
15881588
/** Sets the font used in hover labels. */
15891589
font?: FontArray;
15901590
/**
1591-
* Sets the default length (in number of characters) of the trace name in the hover labels for all traces. -1 shows the whole name regardless of length. 0-3 shows the first 0-3 characters, and an integer >3 will show the whole name if it is less than that many characters, but if it is longer, will truncate to `namelength - 3` characters and add an ellipsis.
1591+
* Sets the default length (in number of characters) of the trace name in the hover labels for all traces. -1 shows the whole name regardless of length. 0-3 shows the first 0-3 characters, and an integer >3 will show the whole name if it is less than that many characters, but if it is longer, will truncate to `namelength - 3` characters and add an ellipsis. A name that is a single tex expression is always shown in full.
15921592
* @default 15
15931593
* Minimum: -1
15941594
*/
@@ -6085,7 +6085,7 @@ export interface OhlcData {
60856085
/** Sets the font used in hover labels. */
60866086
font?: FontArray;
60876087
/**
6088-
* Sets the default length (in number of characters) of the trace name in the hover labels for all traces. -1 shows the whole name regardless of length. 0-3 shows the first 0-3 characters, and an integer >3 will show the whole name if it is less than that many characters, but if it is longer, will truncate to `namelength - 3` characters and add an ellipsis.
6088+
* Sets the default length (in number of characters) of the trace name in the hover labels for all traces. -1 shows the whole name regardless of length. 0-3 shows the first 0-3 characters, and an integer >3 will show the whole name if it is less than that many characters, but if it is longer, will truncate to `namelength - 3` characters and add an ellipsis. A name that is a single tex expression is always shown in full.
60896089
* @default 15
60906090
* Minimum: -1
60916091
*/
@@ -16276,7 +16276,7 @@ export interface Layout {
1627616276
/** Sets the font for group titles in hover (unified modes). Defaults to `hoverlabel.font`. */
1627716277
grouptitlefont?: Font;
1627816278
/**
16279-
* Sets the default length (in number of characters) of the trace name in the hover labels for all traces. -1 shows the whole name regardless of length. 0-3 shows the first 0-3 characters, and an integer >3 will show the whole name if it is less than that many characters, but if it is longer, will truncate to `namelength - 3` characters and add an ellipsis.
16279+
* Sets the default length (in number of characters) of the trace name in the hover labels for all traces. -1 shows the whole name regardless of length. 0-3 shows the first 0-3 characters, and an integer >3 will show the whole name if it is less than that many characters, but if it is longer, will truncate to `namelength - 3` characters and add an ellipsis. A name that is a single tex expression is always shown in full.
1628016280
* @default 15
1628116281
* Minimum: -1
1628216282
*/

‎test/jasmine/bundle_tests/mathjax_test.js‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,52 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() {
734734
.then(done, done.fail);
735735
});
736736

737+
it('should keep a pure-tex main label inside its box for rotated labels in hovermode y', function(done) {
738+
var template = '$\\sum_{i=1}^{n} x_i$<extra>one</extra>';
739+
740+
Plotly.newPlot(gd, {
741+
data: [
742+
{type: 'scatter', mode: 'markers', x: [1], y: [1], hovertemplate: template},
743+
{type: 'scatter', mode: 'markers', x: [2], y: [1], hovertemplate: template}
744+
],
745+
layout: {
746+
width: 500,
747+
height: 400,
748+
margin: {l: 0, t: 0, r: 0, b: 0},
749+
xaxis: {range: [0, 4]},
750+
yaxis: {range: [0, 4]},
751+
hovermode: 'y'
752+
}
753+
})
754+
.then(function() {
755+
_hover(125, 300);
756+
return delay(30)();
757+
})
758+
.then(function() {
759+
var hoverTexts = d3Select(gd).selectAll('g.hovertext');
760+
761+
expect(hoverTexts.size()).toBe(2, 'two hover labels');
762+
expect(hoverTexts.attr('transform')).toContain('rotate', 'labels are rotated');
763+
764+
hoverTexts.each(function() {
765+
var g = d3Select(this);
766+
var mathGroup = g.select('.nums-math-group');
767+
768+
expect(mathGroup.size()).toBe(1, 'main label math group');
769+
770+
var m = mathGroup.select('svg g').node().getBoundingClientRect();
771+
var b = g.select('path').node().getBoundingClientRect();
772+
var tol = 1;
773+
774+
expect(m.left).not.toBeLessThan(b.left - tol, 'tex inside the box, left');
775+
expect(m.right).not.toBeGreaterThan(b.right + tol, 'tex inside the box, right');
776+
expect(m.top).not.toBeLessThan(b.top - tol, 'tex inside the box, top');
777+
expect(m.bottom).not.toBeGreaterThan(b.bottom + tol, 'tex inside the box, bottom');
778+
});
779+
})
780+
.then(done, done.fail);
781+
});
782+
737783
it('should hand a pure-tex Fx.loneHover label off to MathJax, sized to its final content', function(done) {
738784
Plotly.newPlot(gd, {
739785
data: [{type: 'scatter', mode: 'markers', x: [1], y: [1]}],

0 commit comments

Comments
 (0)