diff --git a/draftlogs/8051_fix.md b/draftlogs/8051_fix.md new file mode 100644 index 00000000000..98140f3d3a9 --- /dev/null +++ b/draftlogs/8051_fix.md @@ -0,0 +1 @@ +- Fix hover labels ignoring MathJax for text that is a single tex expression [[#8051](https://github.com/plotly/plotly.js/pull/8051)] diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 92c7dd8b784..f532f0c5e49 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -960,6 +960,28 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { if (!helpers.isUnifiedHover(hovermode)) { hoverAvoidOverlaps(hoverLabels, rotateLabels, fullLayout, hoverText.commonLabelBoundingBox); alignHoverText(hoverLabels, rotateLabels, fullLayout._invScaleX, fullLayout._invScaleY); + + // Pure-tex label parts are typeset by MathJax asynchronously, so the + // sizes used just above can be stale. Once every such part has its + // final size, redo the same (idempotent) overlap/alignment pass. + if (hoverText.mathjaxPromise) { + hoverText.mathjaxPromise.then(function () { + // A newer hover call already replaced this one; nothing to fix. + if (gd._hoverdata !== newhoverdata) return; + hoverAvoidOverlaps(hoverLabels, rotateLabels, fullLayout, hoverText.commonLabelBoundingBox); + alignHoverText(hoverLabels, rotateLabels, fullLayout._invScaleX, fullLayout._invScaleY); + + // alignHoverText just moved text.nums/text.name to their + // final spot; convertToTspans positioned each math group + // once already, from the pre-final placeholder position, so + // make them follow. + hoverLabels.each(function () { + var g = d3.select(this); + svgTextUtils.repositionMathGroup(g.select('text.nums')); + svgTextUtils.repositionMathGroup(g.select('text.name')); + }); + }); + } } // TODO: tagName hack is needed to appease geo.js's hack of using eventTarget=true // we should improve the "fx" API so other plots can use it without these hack. if (eventTarget && eventTarget.tagName) { @@ -1002,6 +1024,15 @@ function hoverDataKey(d) { var EXTRA_STRING_REGEX = /([\s\S]*)<\/extra>/; +// svgTextUtils.convertToTspans hides the source node and appends a +// sibling '-math-group' once MathJax has typeset it; measure +// that group instead of the (now empty) text node when it's present. +function getHoverTextBBox(gd, textSel, baseClass) { + var node = textSel.node(); + var mathGroup = d3.select(node.parentNode).select('.' + baseClass + '-math-group'); + return getBoundingClientRect(gd, mathGroup.empty() ? node : mathGroup.node()); +} + function createHoverText(hoverData, opts) { var gd = opts.gd; var fullLayout = gd._fullLayout; @@ -1014,6 +1045,11 @@ function createHoverText(hoverData, opts) { // Early exit if no labels are drawn if (hoverData.length === 0) return [[]]; + // Resolved once every label whose text is pure tex has been typeset by + // MathJax and repositioned using its final size. Empty when no label + // contains tex, so the common (non-math) case pays nothing extra. + var mathjaxPromises = []; + // opts.fontFamily/Size are used for the common label // and as defaults for each hover label, though the individual labels // can override this. @@ -1090,11 +1126,7 @@ function createHoverText(hoverData, opts) { var lpath = Lib.ensureSingle(label, 'path', '', function (s) { s.style({ 'stroke-width': '1px' }); }); - var ltext = Lib.ensureSingle(label, 'text', '', function (s) { - // prohibit tex interpretation until we can handle - // tex and regular text together - s.attr('data-notex', 1); - }); + var ltext = Lib.ensureSingle(label, 'text', ''); var commonBgColor = commonLabelOpts.bgcolor || Color.defaultLine; var commonStroke = commonLabelOpts.bordercolor || Color.contrast(commonBgColor); @@ -1117,180 +1149,226 @@ function createHoverText(hoverData, opts) { stroke: commonStroke }); + var commonLabelTex = svgTextUtils.isPureTex(String(t0)); + var onCommonLabelReady; + + if (commonLabelTex) { + mathjaxPromises.push( + new Promise(function (resolve) { + onCommonLabelReady = function () { + // Unified hover discards this whole g.hovertext + // structure synchronously and draws a legend-based + // label instead (see below); by the time MathJax + // resolves there may be nothing left to reposition. + if (!ltext.node().parentNode) { + resolve(); + return; + } + positionCommonLabel(); + // positionCommonLabel just moved ltext to its final + // spot; convertToTspans positioned the math group + // once already, from ltext's pre-final placeholder + // position, so make it follow. + svgTextUtils.repositionMathGroup(ltext); + resolve(); + }; + }) + ); + } + ltext .text(t0) .call(Drawing.font, commonLabelFont) + .attr('data-notex', commonLabelTex ? null : 1) .call(svgTextUtils.positionText, 0, 0) - .call(svgTextUtils.convertToTspans, gd); - - label.attr('transform', ''); - - var tbb = getBoundingClientRect(gd, ltext.node()); - var lx, ly; - - if (hovermode === 'x') { - var topsign = xa.side === 'top' ? '-' : ''; + .call(svgTextUtils.convertToTspans, gd, onCommonLabelReady); + + // Position immediately with whatever size is available now (the + // final tex size isn't ready yet, but every field below must be a + // real number before hoverAvoidOverlaps/alignHoverText run, or the + // NaNs they produce stick around even after the tex-triggered + // re-run corrects the inputs). onCommonLabelReady repeats this once + // MathJax has typeset the label. + positionCommonLabel(); + + function positionCommonLabel() { + // tbb below reads *absolute* screen position, which is only + // meaningful measured from this neutral state (no outer + // transform, text at its own local origin) -- the same state + // this label started from the first time this ran. A tex label + // reruns this after it already moved things once; reset first + // so the two runs measure the same way. + label.attr('transform', ''); + svgTextUtils.positionText(ltext, 0, 0); + svgTextUtils.repositionMathGroup(ltext); + + var tbb = getHoverTextBBox(gd, ltext, 'text'); + var lx, ly; + + if (hovermode === 'x') { + var topsign = xa.side === 'top' ? '-' : ''; + + ltext + .attr('text-anchor', 'middle') + .call( + svgTextUtils.positionText, + 0, + xa.side === 'top' + ? outerTop - tbb.bottom - HOVERARROWSIZE - HOVERTEXTPAD + : outerTop - tbb.top + HOVERARROWSIZE + HOVERTEXTPAD + ); + + lx = xa._offset + (c0.x0 + c0.x1) / 2; + ly = ya._offset + (xa.side === 'top' ? 0 : ya._length); + + var halfWidth = tbb.width / 2 + HOVERTEXTPAD; + + var tooltipMidX = lx; + if (lx < halfWidth) { + tooltipMidX = halfWidth; + } else if (lx > fullLayout.width - halfWidth) { + tooltipMidX = fullLayout.width - halfWidth; + } - ltext - .attr('text-anchor', 'middle') - .call( - svgTextUtils.positionText, - 0, - xa.side === 'top' - ? outerTop - tbb.bottom - HOVERARROWSIZE - HOVERTEXTPAD - : outerTop - tbb.top + HOVERARROWSIZE + HOVERTEXTPAD + lpath.attr( + 'd', + 'M' + + (lx - tooltipMidX) + + ',0' + + 'L' + + (lx - tooltipMidX + HOVERARROWSIZE) + + ',' + + topsign + + HOVERARROWSIZE + + 'H' + + halfWidth + + 'v' + + topsign + + (HOVERTEXTPAD * 2 + tbb.height) + + 'H' + + -halfWidth + + 'V' + + topsign + + HOVERARROWSIZE + + 'H' + + (lx - tooltipMidX - HOVERARROWSIZE) + + 'Z' ); - lx = xa._offset + (c0.x0 + c0.x1) / 2; - ly = ya._offset + (xa.side === 'top' ? 0 : ya._length); - - var halfWidth = tbb.width / 2 + HOVERTEXTPAD; - - var tooltipMidX = lx; - if (lx < halfWidth) { - tooltipMidX = halfWidth; - } else if (lx > fullLayout.width - halfWidth) { - tooltipMidX = fullLayout.width - halfWidth; - } - - lpath.attr( - 'd', - 'M' + - (lx - tooltipMidX) + - ',0' + - 'L' + - (lx - tooltipMidX + HOVERARROWSIZE) + - ',' + - topsign + - HOVERARROWSIZE + - 'H' + - halfWidth + - 'v' + - topsign + - (HOVERTEXTPAD * 2 + tbb.height) + - 'H' + - -halfWidth + - 'V' + - topsign + - HOVERARROWSIZE + - 'H' + - (lx - tooltipMidX - HOVERARROWSIZE) + - 'Z' - ); - - lx = tooltipMidX; - commonLabelRect.minX = lx - halfWidth; - commonLabelRect.maxX = lx + halfWidth; - if (xa.side === 'top') { - // label on negative y side - commonLabelRect.minY = ly - (HOVERTEXTPAD * 2 + tbb.height); - commonLabelRect.maxY = ly - HOVERTEXTPAD; - } else { - commonLabelRect.minY = ly + HOVERTEXTPAD; - commonLabelRect.maxY = ly + (HOVERTEXTPAD * 2 + tbb.height); - } - } else { - var anchor; - var sgn; - var leftsign; - if (ya.side === 'right') { - anchor = 'start'; - sgn = 1; - leftsign = ''; - lx = xa._offset + xa._length; + lx = tooltipMidX; + commonLabelRect.minX = lx - halfWidth; + commonLabelRect.maxX = lx + halfWidth; + if (xa.side === 'top') { + // label on negative y side + commonLabelRect.minY = ly - (HOVERTEXTPAD * 2 + tbb.height); + commonLabelRect.maxY = ly - HOVERTEXTPAD; + } else { + commonLabelRect.minY = ly + HOVERTEXTPAD; + commonLabelRect.maxY = ly + (HOVERTEXTPAD * 2 + tbb.height); + } } else { - anchor = 'end'; - sgn = -1; - leftsign = '-'; - lx = xa._offset; - } + var anchor; + var sgn; + var leftsign; + if (ya.side === 'right') { + anchor = 'start'; + sgn = 1; + leftsign = ''; + lx = xa._offset + xa._length; + } else { + anchor = 'end'; + sgn = -1; + leftsign = '-'; + lx = xa._offset; + } - ly = ya._offset + (c0.y0 + c0.y1) / 2; - - ltext.attr('text-anchor', anchor); - - lpath.attr( - 'd', - 'M0,0' + - 'L' + - leftsign + - HOVERARROWSIZE + - ',' + - HOVERARROWSIZE + - 'V' + - (HOVERTEXTPAD + tbb.height / 2) + - 'h' + - leftsign + - (HOVERTEXTPAD * 2 + tbb.width) + - 'V-' + - (HOVERTEXTPAD + tbb.height / 2) + - 'H' + - leftsign + - HOVERARROWSIZE + - 'V-' + - HOVERARROWSIZE + - 'Z' - ); + ly = ya._offset + (c0.y0 + c0.y1) / 2; + + ltext.attr('text-anchor', anchor); + + lpath.attr( + 'd', + 'M0,0' + + 'L' + + leftsign + + HOVERARROWSIZE + + ',' + + HOVERARROWSIZE + + 'V' + + (HOVERTEXTPAD + tbb.height / 2) + + 'h' + + leftsign + + (HOVERTEXTPAD * 2 + tbb.width) + + 'V-' + + (HOVERTEXTPAD + tbb.height / 2) + + 'H' + + leftsign + + HOVERARROWSIZE + + 'V-' + + HOVERARROWSIZE + + 'Z' + ); - commonLabelRect.minY = ly - (HOVERTEXTPAD + tbb.height / 2); - commonLabelRect.maxY = ly + (HOVERTEXTPAD + tbb.height / 2); - if (ya.side === 'right') { - commonLabelRect.minX = lx + HOVERARROWSIZE; - commonLabelRect.maxX = lx + HOVERARROWSIZE + (HOVERTEXTPAD * 2 + tbb.width); - } else { - // label on negative x side - commonLabelRect.minX = lx - HOVERARROWSIZE - (HOVERTEXTPAD * 2 + tbb.width); - commonLabelRect.maxX = lx - HOVERARROWSIZE; - } + commonLabelRect.minY = ly - (HOVERTEXTPAD + tbb.height / 2); + commonLabelRect.maxY = ly + (HOVERTEXTPAD + tbb.height / 2); + if (ya.side === 'right') { + commonLabelRect.minX = lx + HOVERARROWSIZE; + commonLabelRect.maxX = lx + HOVERARROWSIZE + (HOVERTEXTPAD * 2 + tbb.width); + } else { + // label on negative x side + commonLabelRect.minX = lx - HOVERARROWSIZE - (HOVERTEXTPAD * 2 + tbb.width); + commonLabelRect.maxX = lx - HOVERARROWSIZE; + } - var halfHeight = tbb.height / 2; - var lty = outerTop - tbb.top - halfHeight; - var clipId = 'clip' + fullLayout._uid + 'commonlabel' + ya._id; - var clipPath; - - if (lx < tbb.width + 2 * HOVERTEXTPAD + HOVERARROWSIZE) { - clipPath = - 'M-' + - (HOVERARROWSIZE + HOVERTEXTPAD) + - '-' + - halfHeight + - 'h-' + - (tbb.width - HOVERTEXTPAD) + - 'V' + - halfHeight + - 'h' + - (tbb.width - HOVERTEXTPAD) + - 'Z'; - - var ltx = tbb.width - lx + HOVERTEXTPAD; - svgTextUtils.positionText(ltext, ltx, lty); - - // shift each line (except the longest) so that start-of-line - // is always visible - if (anchor === 'end') { - ltext.selectAll('tspan').each(function () { - var s = d3.select(this); - var dummy = Drawing.tester.append('text').text(s.text()).call(Drawing.font, commonLabelFont); - var dummyBB = getBoundingClientRect(gd, dummy.node()); - if (Math.round(dummyBB.width) < Math.round(tbb.width)) { - s.attr('x', ltx - dummyBB.width); - } - dummy.remove(); - }); + var halfHeight = tbb.height / 2; + var lty = outerTop - tbb.top - halfHeight; + var clipId = 'clip' + fullLayout._uid + 'commonlabel' + ya._id; + var clipPath; + + if (lx < tbb.width + 2 * HOVERTEXTPAD + HOVERARROWSIZE) { + clipPath = + 'M-' + + (HOVERARROWSIZE + HOVERTEXTPAD) + + '-' + + halfHeight + + 'h-' + + (tbb.width - HOVERTEXTPAD) + + 'V' + + halfHeight + + 'h' + + (tbb.width - HOVERTEXTPAD) + + 'Z'; + + var ltx = tbb.width - lx + HOVERTEXTPAD; + svgTextUtils.positionText(ltext, ltx, lty); + + // shift each line (except the longest) so that start-of-line + // is always visible + if (anchor === 'end') { + ltext.selectAll('tspan').each(function () { + var s = d3.select(this); + var dummy = Drawing.tester.append('text').text(s.text()).call(Drawing.font, commonLabelFont); + var dummyBB = getBoundingClientRect(gd, dummy.node()); + if (Math.round(dummyBB.width) < Math.round(tbb.width)) { + s.attr('x', ltx - dummyBB.width); + } + dummy.remove(); + }); + } + } else { + svgTextUtils.positionText(ltext, sgn * (HOVERTEXTPAD + HOVERARROWSIZE), lty); + clipPath = null; } - } else { - svgTextUtils.positionText(ltext, sgn * (HOVERTEXTPAD + HOVERARROWSIZE), lty); - clipPath = null; + + var textClip = fullLayout._topclips.selectAll('#' + clipId).data(clipPath ? [0] : []); + textClip.enter().append('clipPath').attr('id', clipId).append('path'); + textClip.exit().remove(); + textClip.select('path').attr('d', clipPath); + Drawing.setClipUrl(ltext, clipPath ? clipId : null, gd); } - var textClip = fullLayout._topclips.selectAll('#' + clipId).data(clipPath ? [0] : []); - textClip.enter().append('clipPath').attr('id', clipId).append('path'); - textClip.exit().remove(); - textClip.select('path').attr('d', clipPath); - Drawing.setClipUrl(ltext, clipPath ? clipId : null, gd); + label.attr('transform', strTranslate(lx, ly)); } - - label.attr('transform', strTranslate(lx, ly)); }); // Show a single hover label @@ -1562,6 +1640,29 @@ function createHoverText(hoverData, opts) { var texts = getHoverLabelText(d, showCommonLabel, hovermode, fullLayout, t0, g); var text = texts[0]; var name = texts[1]; + var hasName = !!(name && name !== text); + + var numsTex = svgTextUtils.isPureTex(text); + var nameTex = hasName && svgTextUtils.isPureTex(name); + var pendingLabelParts = (numsTex ? 1 : 0) + (nameTex ? 1 : 0); + var onLabelPartReady; + + if (pendingLabelParts) { + mathjaxPromises.push( + new Promise(function (resolve) { + onLabelPartReady = function () { + if (--pendingLabelParts === 0) { + // Unified hover discards this whole g.hovertext + // structure synchronously and draws a legend- + // based label instead; by the time MathJax + // resolves there may be nothing left to redo. + if (g.node().parentNode) finalizePosition(); + resolve(); + } + }; + }) + ); + } // main label var tx = g @@ -1578,16 +1679,14 @@ function createHoverText(hoverData, opts) { shadow: d.fontShadow || fontShadow }) .text(text) - .attr('data-notex', 1) + .attr('data-notex', numsTex ? null : 1) .call(svgTextUtils.positionText, 0, 0) - .call(svgTextUtils.convertToTspans, gd); + .call(svgTextUtils.convertToTspans, gd, numsTex ? onLabelPartReady : undefined); var tx2 = g.select('text.name'); - var tx2width = 0; - var tx2height = 0; // secondary label for non-empty 'name' - if (name && name !== text) { + if (hasName) { tx2.call(Drawing.font, { family: d.fontFamily || fontFamily, size: d.fontSize || fontSize, @@ -1600,88 +1699,117 @@ function createHoverText(hoverData, opts) { shadow: d.fontShadow || fontShadow }) .text(name) - .attr('data-notex', 1) + .attr('data-notex', nameTex ? null : 1) .call(svgTextUtils.positionText, 0, 0) - .call(svgTextUtils.convertToTspans, gd); - - var t2bb = getBoundingClientRect(gd, tx2.node()); - tx2width = t2bb.width + 2 * HOVERTEXTPAD; - tx2height = t2bb.height + 2 * HOVERTEXTPAD; + .call(svgTextUtils.convertToTspans, gd, nameTex ? onLabelPartReady : undefined); } else { tx2.remove(); g.select('rect').remove(); } - g.select('path').style({ - fill: numsColor, - stroke: contrastColor - }); + // Position immediately with whatever size is available now (the + // final tex size isn't ready yet, but every field this sets must be + // a real number before hoverAvoidOverlaps/alignHoverText run, or the + // NaNs they produce stick around even after the tex-triggered + // re-run corrects the inputs). onLabelPartReady repeats this once + // every tex part has been typeset by MathJax. + finalizePosition(); + + function finalizePosition() { + // tbb/t2bb below read *absolute* screen position, which is only + // meaningful measured from this neutral state (no outer + // transform, text at its own local origin) -- the same state + // this label started from the first time this ran. A tex label + // reruns this after alignHoverText has already moved things + // once; reset first so the two runs measure the same way. + g.attr('transform', ''); + tx.call(svgTextUtils.positionText, 0, 0); + svgTextUtils.repositionMathGroup(tx); + if (hasName) { + tx2.call(svgTextUtils.positionText, 0, 0); + svgTextUtils.repositionMathGroup(tx2); + } - var htx = d.xa._offset + (d.x0 + d.x1) / 2; - var hty = d.ya._offset + (d.y0 + d.y1) / 2; - var dx = Math.abs(d.x1 - d.x0); - var dy = Math.abs(d.y1 - d.y0); - - var tbb = getBoundingClientRect(gd, tx.node()); - var tbbWidth = tbb.width / fullLayout._invScaleX; - var tbbHeight = tbb.height / fullLayout._invScaleY; - - d.ty0 = (outerTop - tbb.top) / fullLayout._invScaleY; - d.bx = tbbWidth + 2 * HOVERTEXTPAD; - d.by = Math.max(tbbHeight + 2 * HOVERTEXTPAD, tx2height); - d.anchor = 'start'; - d.txwidth = tbbWidth; - d.tx2width = tx2width; - d.offset = 0; - - var txTotalWidth = (tbbWidth + HOVERARROWSIZE + HOVERTEXTPAD + tx2width) * fullLayout._invScaleX; - var anchorStartOK, anchorEndOK; - - if (rotateLabels) { - d.pos = htx; - anchorStartOK = hty + dy / 2 + txTotalWidth <= outerHeight; - anchorEndOK = hty - dy / 2 - txTotalWidth >= 0; - if ((d.idealAlign === 'top' || !anchorStartOK) && anchorEndOK) { - hty -= dy / 2; - d.anchor = 'end'; - } else if (anchorStartOK) { - hty += dy / 2; - d.anchor = 'start'; - } else { - d.anchor = 'middle'; + g.select('path').style({ + fill: numsColor, + stroke: contrastColor + }); + + var htx = d.xa._offset + (d.x0 + d.x1) / 2; + var hty = d.ya._offset + (d.y0 + d.y1) / 2; + var dx = Math.abs(d.x1 - d.x0); + var dy = Math.abs(d.y1 - d.y0); + + var tx2width = 0; + var tx2height = 0; + if (hasName) { + var t2bb = getHoverTextBBox(gd, tx2, 'name'); + tx2width = t2bb.width + 2 * HOVERTEXTPAD; + tx2height = t2bb.height + 2 * HOVERTEXTPAD; } - d.crossPos = hty; - } else { - d.pos = hty; - anchorStartOK = htx + dx / 2 + txTotalWidth <= outerWidth; - anchorEndOK = htx - dx / 2 - txTotalWidth >= 0; - - if ((d.idealAlign === 'left' || !anchorStartOK) && anchorEndOK) { - htx -= dx / 2; - d.anchor = 'end'; - } else if (anchorStartOK) { - htx += dx / 2; - d.anchor = 'start'; + + var tbb = getHoverTextBBox(gd, tx, 'nums'); + var tbbWidth = tbb.width / fullLayout._invScaleX; + var tbbHeight = tbb.height / fullLayout._invScaleY; + + d.ty0 = (outerTop - tbb.top) / fullLayout._invScaleY; + d.bx = tbbWidth + 2 * HOVERTEXTPAD; + d.by = Math.max(tbbHeight + 2 * HOVERTEXTPAD, tx2height); + d.anchor = 'start'; + d.txwidth = tbbWidth; + d.tx2width = tx2width; + d.offset = 0; + + var txTotalWidth = (tbbWidth + HOVERARROWSIZE + HOVERTEXTPAD + tx2width) * fullLayout._invScaleX; + var anchorStartOK, anchorEndOK; + + if (rotateLabels) { + d.pos = htx; + anchorStartOK = hty + dy / 2 + txTotalWidth <= outerHeight; + anchorEndOK = hty - dy / 2 - txTotalWidth >= 0; + if ((d.idealAlign === 'top' || !anchorStartOK) && anchorEndOK) { + hty -= dy / 2; + d.anchor = 'end'; + } else if (anchorStartOK) { + hty += dy / 2; + d.anchor = 'start'; + } else { + d.anchor = 'middle'; + } + d.crossPos = hty; } else { - d.anchor = 'middle'; + d.pos = hty; + anchorStartOK = htx + dx / 2 + txTotalWidth <= outerWidth; + anchorEndOK = htx - dx / 2 - txTotalWidth >= 0; + + if ((d.idealAlign === 'left' || !anchorStartOK) && anchorEndOK) { + htx -= dx / 2; + d.anchor = 'end'; + } else if (anchorStartOK) { + htx += dx / 2; + d.anchor = 'start'; + } else { + d.anchor = 'middle'; - var txHalfWidth = txTotalWidth / 2; - var overflowR = htx + txHalfWidth - outerWidth; - var overflowL = htx - txHalfWidth; - if (overflowR > 0) htx -= overflowR; - if (overflowL < 0) htx += -overflowL; + var txHalfWidth = txTotalWidth / 2; + var overflowR = htx + txHalfWidth - outerWidth; + var overflowL = htx - txHalfWidth; + if (overflowR > 0) htx -= overflowR; + if (overflowL < 0) htx += -overflowL; + } + d.crossPos = htx; } - d.crossPos = htx; - } - tx.attr('text-anchor', d.anchor); - if (tx2width) tx2.attr('text-anchor', d.anchor); - g.attr('transform', strTranslate(htx, hty) + (rotateLabels ? strRotate(YANGLE) : '')); + tx.attr('text-anchor', d.anchor); + if (tx2width) tx2.attr('text-anchor', d.anchor); + g.attr('transform', strTranslate(htx, hty) + (rotateLabels ? strRotate(YANGLE) : '')); + } }); return { hoverLabels: hoverLabels, - commonLabelBoundingBox: commonLabelRect + commonLabelBoundingBox: commonLabelRect, + mathjaxPromise: mathjaxPromises.length ? Promise.all(mathjaxPromises) : null }; } diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js index 7ed481e2393..42562312c0f 100644 --- a/src/components/legend/draw.js +++ b/src/components/legend/draw.js @@ -733,7 +733,11 @@ function setupTitleToggle(scrollBox, gd, legendObj, legendId) { function textLayout(s, g, gd, legendObj, aTitle) { - if(legendObj._inHover) s.attr('data-notex', true); // do not process MathJax for unified hover + // unified hover keeps MathJax off unless the whole label is one tex + // expression: convertToTspans only typesets the delimited part of a + // match, silently dropping any surrounding text, which most unified + // hover labels have (e.g. a trace name next to a formatted value). + if(legendObj._inHover && !svgTextUtils.isPureTex(s.text())) s.attr('data-notex', true); svgTextUtils.convertToTspans(s, gd, function() { computeTextDimensions(g, gd, legendObj, aTitle); }); diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index e2e222b2648..1c567e5329c 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -24,6 +24,23 @@ var FIND_TEX = /([^$]*)([$]+[^$]*[$]+)([^$]*)/; const matchTex = (str) => str ? str.match(FIND_TEX) : null; exports.matchTex = matchTex; +/** + * Checks whether a string is *entirely* a single tex expression, with no + * literal text before or after the $...$ delimiters. + * + * convertToTspans only ever typesets the delimited part of a matchTex() + * result (tex[2]); any surrounding text (tex[1], tex[3]) is silently + * dropped. That's safe here because there is none to drop. + * + * @param {string} str: the string to check for tex + * @return {boolean} true if the whole string is one tex expression + */ +const isPureTex = (str) => { + var tex = matchTex(str); + return !!tex && !tex[1] && !tex[3]; +}; +exports.isPureTex = isPureTex; + exports.convertToTspans = function(_context, gd, _callback) { var str = _context.text(); @@ -189,6 +206,67 @@ exports.convertToTspans = function(_context, gd, _callback) { return _context; }; +/** + * Repositions an existing MathJax-typeset group, previously inserted by + * convertToTspans, to match its source node's *current* x/y and + * text-anchor. + * + * convertToTspans positions a math group once, at typeset time, from the + * source node's x/y at that instant; it never revisits that position. A + * caller that moves the source afterward (e.g. once it learns the + * math group's real size and needs to redo layout that depended on it) + * needs this to make the rendered group follow. Keep the coordinate math + * below in sync with the "else" branch inside convertToTspans. + * + * @param {d3 selection} _context: the source element + */ +exports.repositionMathGroup = function(_context) { + if(_context.empty()) return; + + var svgClass = (_context.attr('class') ? _context.attr('class').split(' ')[0] : 'text') + '-math'; + var parent = d3.select(_context.node().parentNode); + var mathjaxGroup = parent.select('g.' + svgClass + '-group'); + var newSvg = mathjaxGroup.select('svg.' + svgClass); + if(mathjaxGroup.empty() || newSvg.empty()) return; + + var g = newSvg.select('g'); + var bb = g.node().getBoundingClientRect(); + var w = bb.width; + var h = bb.height; + + var x = +_context.attr('x'); + var y = +_context.attr('y'); + + // font baseline is about 1/4 fontSize below centerline + var fontSize = parseInt(_context.node().style.fontSize, 10); + var textHeight = fontSize || _context.node().getBoundingClientRect().height; + var dy = -textHeight / 4; + + if(svgClass[0] === 'y') { + mathjaxGroup.attr({ + transform: 'rotate(' + [-90, x, y] + + ')' + strTranslate(-w / 2, dy - h / 2) + }); + } else if(svgClass[0] === 'l') { + y = dy - h / 2; + } else if(svgClass[0] === 'a' && svgClass.indexOf('atitle') !== 0) { + x = 0; + y = dy; + } else { + var textAnchor = _context.attr('text-anchor'); + x = x - w * ( + textAnchor === 'middle' ? 0.5 : + textAnchor === 'end' ? 1 : 0 + ); + y = y + dy - h / 2; + } + + newSvg.attr({ + x: x, + y: y + }); +}; + // MathJax diff --git a/test/jasmine/bundle_tests/mathjax_test.js b/test/jasmine/bundle_tests/mathjax_test.js index 21704fa8130..16fe50faa77 100644 --- a/test/jasmine/bundle_tests/mathjax_test.js +++ b/test/jasmine/bundle_tests/mathjax_test.js @@ -1,9 +1,12 @@ var Plotly = require('../../../lib/index'); +var Fx = require('../../../src/components/fx'); +var Lib = require('../../../src/lib'); var d3Select = require('../../strict-d3').select; var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var loadScript = require('../assets/load_script'); +var delay = require('../assets/delay'); // eslint-disable-next-line no-undef var mathjaxVersion = __karma__.config.mathjaxVersion; @@ -203,4 +206,140 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() { .then(done, done.fail); }); }); + + describe('Test hover tex rendering:', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function _hover(xpx, ypx) { + // 'xy' is the subplot id, not the hovermode -- hovermode comes + // from the figure's own layout.hovermode. + Fx.hover(gd, {xpx: xpx, ypx: ypx}, 'xy'); + Lib.clearThrottle(); + } + + it('should hand a pure-tex hover label off to MathJax, sized to its final content', function(done) { + Plotly.newPlot(gd, { + data: [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + text: ['$\\alpha^2 + \\beta^2 = \\gamma^2$', 'b', 'c'], + hoverinfo: 'text' + }], + 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 gd3 = d3Select(gd); + var mathGroup = gd3.select('g.hovertext .nums-math-group'); + + expect(mathGroup.size()).toBe(1, 'hover label math group'); + expect(mathGroup.attr('data-unformatted')).toBe('$\\alpha^2 + \\beta^2 = \\gamma^2$'); + + // A rendered formula this long is much wider than the + // ~15px placeholder box a not-yet-typeset label starts at; + // this is what distinguishes a corrected box from one still + // sized off the pre-MathJax placeholder measurement. + var bg = gd3.select('g.hovertext > path').node().getBBox(); + expect(bg.width).toBeGreaterThan(50, 'hover box width, once corrected for the real label size'); + + // The path/text/math-group coordinates must all be real + // numbers -- this is what distinguishes a corrected box + // from one still carrying NaN from an unresolved layout. + expect(gd3.select('g.hovertext > path').attr('d')).not.toContain('NaN'); + expect(mathGroup.select('svg').attr('x')).not.toBe('NaN'); + expect(mathGroup.select('svg').attr('y')).not.toBe('NaN'); + }) + .then(done, done.fail); + }); + + it('should leave a mixed tex/plain-text hover label as literal text', function(done) { + Plotly.newPlot(gd, { + data: [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + text: ['Value: $\\alpha$ units', 'b', 'c'], + hoverinfo: 'text' + }], + 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 gd3 = d3Select(gd); + var numsText = gd3.select('g.hovertext text.nums'); + + expect(gd3.select('g.hovertext .nums-math-group').size()).toBe(0, 'no math group'); + expect(numsText.text()).toBe('Value: $\\alpha$ units'); + expect(numsText.node().style.display).not.toBe('none'); + }) + .then(done, done.fail); + }); + + it('should hand a pure-tex common hover label off to MathJax, sized to its final content', function(done) { + Plotly.newPlot(gd, { + data: [{ + type: 'scatter', + mode: 'markers', + x: ['$\\alpha^2 + \\beta^2 = \\gamma^2$', 'b', 'c'], + y: [1, 2, 3] + }], + layout: { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0}, + // category positions are 0, 1, 2; this range puts + // category 0 at pixel 125, matching the other two tests + xaxis: {range: [-1, 3]}, + yaxis: {range: [0, 4]}, + hovermode: 'x' + } + }) + .then(function() { + _hover(125, 300); + return delay(30)(); + }) + .then(function() { + var gd3 = d3Select(gd); + var mathGroup = gd3.select('g.axistext .text-math-group'); + + expect(mathGroup.size()).toBe(1, 'common label math group'); + expect(mathGroup.attr('data-unformatted')).toBe('$\\alpha^2 + \\beta^2 = \\gamma^2$'); + + var bg = gd3.select('g.axistext > path').node().getBBox(); + expect(bg.width).toBeGreaterThan(50, 'common label width, once corrected for the real label size'); + + expect(gd3.select('g.axistext > path').attr('d')).not.toContain('NaN'); + expect(mathGroup.select('svg').attr('x')).not.toBe('NaN'); + expect(mathGroup.select('svg').attr('y')).not.toBe('NaN'); + }) + .then(done, done.fail); + }); + }); });