diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 40020c4ef0f..b8b3f528470 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -925,15 +925,10 @@ function createHoverText(hoverData, opts, gd) { if(d.nameOverride !== undefined) d.name = d.nameOverride; if(d.name) { - // strip out our pseudo-html elements from d.name (if it exists at all) - name = svgTextUtils.plainText(d.name || ''); - - var nameLength = Math.round(d.nameLength); - - if(nameLength > -1 && name.length > nameLength) { - if(nameLength > 3) name = name.substr(0, nameLength - 3) + '...'; - else name = name.substr(0, nameLength); - } + name = svgTextUtils.plainText(d.name || '', { + len: d.nameLength, + allowedTags: ['br', 'sub', 'sup', 'b', 'i', 'em'] + }); } if(d.zLabel !== undefined) { @@ -995,6 +990,7 @@ function createHoverText(hoverData, opts, gd) { var tx2 = g.select('text.name'); var tx2width = 0; + var tx2height = 0; // secondary label for non-empty 'name' if(name && name !== text) { @@ -1006,18 +1002,20 @@ function createHoverText(hoverData, opts, gd) { .attr('data-notex', 1) .call(svgTextUtils.positionText, 0, 0) .call(svgTextUtils.convertToTspans, gd); - tx2width = tx2.node().getBoundingClientRect().width + 2 * HOVERTEXTPAD; - } - else { + + var t2bb = tx2.node().getBoundingClientRect(); + tx2width = t2bb.width + 2 * HOVERTEXTPAD; + tx2height = t2bb.height + 2 * HOVERTEXTPAD; + } else { tx2.remove(); g.select('rect').remove(); } - g.select('path') - .style({ - fill: numsColor, - stroke: contrastColor - }); + g.select('path').style({ + fill: numsColor, + stroke: contrastColor + }); + var tbb = tx.node().getBoundingClientRect(); var htx = d.xa._offset + (d.x0 + d.x1) / 2; var hty = d.ya._offset + (d.y0 + d.y1) / 2; @@ -1028,7 +1026,7 @@ function createHoverText(hoverData, opts, gd) { d.ty0 = outerTop - tbb.top; d.bx = tbb.width + 2 * HOVERTEXTPAD; - d.by = tbb.height + 2 * HOVERTEXTPAD; + d.by = Math.max(tbb.height + 2 * HOVERTEXTPAD, tx2height); d.anchor = 'start'; d.txwidth = tbb.width; d.tx2width = tx2width; diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index 6f269da7c32..c2ddb368251 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -264,8 +264,6 @@ var ZERO_WIDTH_SPACE = '\u200b'; */ var PROTOCOLS = ['http:', 'https:', 'mailto:', '', undefined, ':']; -var STRIP_TAGS = new RegExp(']*)?/?>', 'g'); - var NEWLINES = /(\r\n?|\n)/g; var SPLIT_TAGS = /(<[^<>]*>)/; @@ -315,10 +313,66 @@ function getQuotedMatch(_str, re) { var COLORMATCH = /(^|;)\s*color:/; -exports.plainText = function(_str) { - // strip out our pseudo-html so we have a readable - // version to put into text fields - return (_str || '').replace(STRIP_TAGS, ' '); +/** + * Strip string of tags + * + * @param {string} _str : input string + * @param {object} opts : + * - maxLen {number} max length of output string + * - allowedTags {array} list of pseudo-html tags to NOT strip + * @return {string} + */ +exports.plainText = function(_str, opts) { + opts = opts || {}; + + var len = (opts.len !== undefined && opts.len !== -1) ? opts.len : Infinity; + var allowedTags = opts.allowedTags !== undefined ? opts.allowedTags : ['br']; + + var ellipsis = '...'; + var eLen = ellipsis.length; + + var oldParts = _str.split(SPLIT_TAGS); + var newParts = []; + var prevTag = ''; + var l = 0; + + for(var i = 0; i < oldParts.length; i++) { + var p = oldParts[i]; + var match = p.match(ONE_TAG); + var tagType = match && match[2].toLowerCase(); + + if(tagType) { + // N.B. tags do not count towards string length + if(allowedTags.indexOf(tagType) !== -1) { + newParts.push(p); + prevTag = tagType; + } + } else { + var pLen = p.length; + + if((l + pLen) < len) { + newParts.push(p); + l += pLen; + } else if(l < len) { + var pLen2 = len - l; + + if(prevTag && (prevTag !== 'br' || pLen2 <= eLen || pLen <= eLen)) { + newParts.pop(); + } + + if(len > eLen) { + newParts.push(p.substr(0, pLen2 - eLen) + ellipsis); + } else { + newParts.push(p.substr(0, pLen2)); + } + break; + } + + prevTag = ''; + } + } + + return newParts.join(''); }; /* diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index 3bae88b6024..5d025b8c1d8 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -248,7 +248,7 @@ describe('hover info', function() { assertHoverLabelContent({ nums: '1\nhover text', - name: '<img src=x o...', + name: '', axis: '0.388' }); }); @@ -1432,117 +1432,49 @@ describe('hover info', function() { .catch(failTest) .then(done); }); - }); - function hoverInfoNodes(traceName) { - var g = d3.selectAll('g.hoverlayer g.hovertext').filter(function() { - return !d3.select(this).select('[data-unformatted="' + traceName + '"]').empty(); - }); - - return { - primaryText: g.select('text:not([data-unformatted="' + traceName + '"])').node(), - primaryBox: g.select('path').node(), - secondaryText: g.select('[data-unformatted="' + traceName + '"]').node(), - secondaryBox: g.select('rect').node() - }; - } - - function ensureCentered(hoverInfoNodes) { - expect(hoverInfoNodes.primaryText.getAttribute('text-anchor')).toBe('middle'); - expect(hoverInfoNodes.secondaryText.getAttribute('text-anchor')).toBe('middle'); - return hoverInfoNodes; - } - - function assertLabelsInsideBoxes(nodes, msgPrefix) { - var msgPrefixFmt = msgPrefix ? '[' + msgPrefix + '] ' : ''; - - assertElemInside(nodes.primaryText, nodes.primaryBox, - msgPrefixFmt + 'Primary text inside box'); - assertElemInside(nodes.secondaryText, nodes.secondaryBox, - msgPrefixFmt + 'Secondary text inside box'); - } - - function assertSecondaryRightToPrimaryBox(nodes, msgPrefix) { - var msgPrefixFmt = msgPrefix ? '[' + msgPrefix + '] ' : ''; - - assertElemRightTo(nodes.secondaryBox, nodes.primaryBox, - msgPrefixFmt + 'Secondary box right to primary box'); - assertElemTopsAligned(nodes.secondaryBox, nodes.primaryBox, - msgPrefixFmt + 'Top edges of primary and secondary boxes aligned'); - } - - describe('centered', function() { - var trace1 = { - x: ['giraffes'], - y: [5], - name: 'LA Zoo', - type: 'bar', - text: ['Way too long hover info!'] - }; - var trace2 = { - x: ['giraffes'], - y: [5], - name: 'SF Zoo', - type: 'bar', - text: ['San Francisco'] - }; - var data = [trace1, trace2]; - var layout = {width: 600, height: 300, barmode: 'stack'}; - + describe('alignment while avoiding overlaps:', function() { var gd; - beforeEach(function(done) { - gd = createGraphDiv(); - Plotly.plot(gd, data, layout).then(done); - }); + beforeEach(function() { gd = createGraphDiv(); }); - it('renders labels inside boxes', function() { - _hover(gd, 300, 150); + function hoverInfoNodes(traceName) { + var g = d3.selectAll('g.hoverlayer g.hovertext').filter(function() { + return !d3.select(this).select('[data-unformatted="' + traceName + '"]').empty(); + }); - var nodes = ensureCentered(hoverInfoNodes('LA Zoo')); - assertLabelsInsideBoxes(nodes); - }); + return { + primaryText: g.select('text:not([data-unformatted="' + traceName + '"])').node(), + primaryBox: g.select('path').node(), + secondaryText: g.select('[data-unformatted="' + traceName + '"]').node(), + secondaryBox: g.select('rect').node() + }; + } - it('renders secondary info box right to primary info box', function() { - _hover(gd, 300, 150); + function ensureCentered(hoverInfoNodes) { + expect(hoverInfoNodes.primaryText.getAttribute('text-anchor')).toBe('middle'); + expect(hoverInfoNodes.secondaryText.getAttribute('text-anchor')).toBe('middle'); + return hoverInfoNodes; + } - var nodes = ensureCentered(hoverInfoNodes('LA Zoo')); - assertSecondaryRightToPrimaryBox(nodes); - }); - }); + function assertLabelsInsideBoxes(nodes, msgPrefix) { + var msgPrefixFmt = msgPrefix ? '[' + msgPrefix + '] ' : ''; - describe('centered', function() { - var trace1 = { - x: ['giraffes'], - y: [5], - name: 'LA Zoo', - type: 'bar', - text: ['Way too long hover info!'] - }; - var trace2 = { - x: ['giraffes'], - y: [5], - name: 'SF Zoo', - type: 'bar', - text: ['Way too looooong hover info!'] - }; - var trace3 = { - x: ['giraffes'], - y: [5], - name: 'NY Zoo', - type: 'bar', - text: ['New York'] - }; - var data = [trace1, trace2, trace3]; - var layout = {width: 600, height: 300}; + assertElemInside(nodes.primaryText, nodes.primaryBox, + msgPrefixFmt + 'Primary text inside box'); + assertElemInside(nodes.secondaryText, nodes.secondaryBox, + msgPrefixFmt + 'Secondary text inside box'); + } - var gd; + function assertSecondaryRightToPrimaryBox(nodes, msgPrefix) { + var msgPrefixFmt = msgPrefix ? '[' + msgPrefix + '] ' : ''; - beforeEach(function(done) { - gd = createGraphDiv(); - Plotly.plot(gd, data, layout).then(done); - }); + assertElemRightTo(nodes.secondaryBox, nodes.primaryBox, + msgPrefixFmt + 'Secondary box right to primary box'); + assertElemTopsAligned(nodes.secondaryBox, nodes.primaryBox, + msgPrefixFmt + 'Top edges of primary and secondary boxes aligned'); + } function calcLineOverlap(minA, maxA, minB, maxB) { expect(minA).toBeLessThan(maxA); @@ -1552,23 +1484,126 @@ describe('hover info', function() { return Math.max(0, overlap); } - it('stacks nicely upon each other', function() { - _hover(gd, 300, 150); + it('centered-aligned, should render labels inside boxes', function(done) { + var trace1 = { + x: ['giraffes'], + y: [5], + name: 'LA Zoo', + type: 'bar', + text: ['Way too long hover info!'] + }; + var trace2 = { + x: ['giraffes'], + y: [5], + name: 'SF Zoo', + type: 'bar', + text: ['San Francisco'] + }; + var data = [trace1, trace2]; + var layout = {width: 600, height: 300, barmode: 'stack'}; + + Plotly.plot(gd, data, layout) + .then(function() { _hover(gd, 300, 150); }) + .then(function() { + var nodes = ensureCentered(hoverInfoNodes('LA Zoo')); + assertLabelsInsideBoxes(nodes); + assertSecondaryRightToPrimaryBox(nodes); + }) + .catch(failTest) + .then(done); + }); + + it('centered-aligned, should stack nicely upon each other', function(done) { + var trace1 = { + x: ['giraffes'], + y: [5], + name: 'LA Zoo', + type: 'bar', + text: ['Way too long hover info!'] + }; + var trace2 = { + x: ['giraffes'], + y: [5], + name: 'SF Zoo', + type: 'bar', + text: ['Way too looooong hover info!'] + }; + var trace3 = { + x: ['giraffes'], + y: [5], + name: 'NY Zoo', + type: 'bar', + text: ['New York'] + }; + var data = [trace1, trace2, trace3]; + var layout = {width: 600, height: 300}; - var nodesLA = ensureCentered(hoverInfoNodes('LA Zoo')); - var nodesSF = ensureCentered(hoverInfoNodes('SF Zoo')); + Plotly.plot(gd, data, layout) + .then(function() { _hover(gd, 300, 150); }) + .then(function() { + var nodesLA = ensureCentered(hoverInfoNodes('LA Zoo')); + var nodesSF = ensureCentered(hoverInfoNodes('SF Zoo')); + + // Ensure layout correct + assertLabelsInsideBoxes(nodesLA, 'LA Zoo'); + assertLabelsInsideBoxes(nodesSF, 'SF Zoo'); + assertSecondaryRightToPrimaryBox(nodesLA, 'LA Zoo'); + assertSecondaryRightToPrimaryBox(nodesSF, 'SF Zoo'); + + // Ensure stacking, finally + var boxLABB = nodesLA.primaryBox.getBoundingClientRect(); + var boxSFBB = nodesSF.primaryBox.getBoundingClientRect(); + + // Be robust against floating point arithmetic and subtle future layout changes + expect(calcLineOverlap(boxLABB.top, boxLABB.bottom, boxSFBB.top, boxSFBB.bottom)) + .toBeWithin(0, 1); + }) + .catch(failTest) + .then(done); + }); - // Ensure layout correct - assertLabelsInsideBoxes(nodesLA, 'LA Zoo'); - assertLabelsInsideBoxes(nodesSF, 'SF Zoo'); - assertSecondaryRightToPrimaryBox(nodesLA, 'LA Zoo'); - assertSecondaryRightToPrimaryBox(nodesSF, 'SF Zoo'); + it('should stack multi-line trace-name labels nicely', function(done) { + var name = 'Multi
line
trace
name'; + var name2 = 'Multi
line
trace
name2'; - // Ensure stacking, finally - var boxLABB = nodesLA.primaryBox.getBoundingClientRect(); - var boxSFBB = nodesSF.primaryBox.getBoundingClientRect(); - expect(calcLineOverlap(boxLABB.top, boxLABB.bottom, boxSFBB.top, boxSFBB.bottom)) - .toBeWithin(0, 1); // Be robust against floating point arithmetic and subtle future layout changes + Plotly.plot(gd, [{ + y: [1, 2, 1], + name: name, + hoverlabel: {namelength: -1}, + hoverinfo: 'x+y+name' + }, { + y: [1, 2, 1], + name: name2, + hoverinfo: 'x+y+name', + hoverlabel: {namelength: -1} + }], { + width: 600, + height: 300 + }) + .then(function() { _hoverNatural(gd, 209, 12); }) + .then(function() { + var nodes = hoverInfoNodes(name); + var nodes2 = hoverInfoNodes(name2); + + assertLabelsInsideBoxes(nodes, 'trace 0'); + assertLabelsInsideBoxes(nodes2, 'trace 2'); + assertSecondaryRightToPrimaryBox(nodes, 'trace 0'); + assertSecondaryRightToPrimaryBox(nodes2, 'trace 2'); + + var primaryBB = nodes.primaryBox.getBoundingClientRect(); + var primaryBB2 = nodes2.primaryBox.getBoundingClientRect(); + expect(calcLineOverlap(primaryBB.top, primaryBB.bottom, primaryBB2.top, primaryBB2.bottom)) + .toBeWithin(0, 1); + + // there's a bit of a gap in the secondary as they do have + // a border (for now) + var secondaryBB = nodes.secondaryBox.getBoundingClientRect(); + var secondaryBB2 = nodes2.secondaryBox.getBoundingClientRect(); + expect(calcLineOverlap(secondaryBB.top, secondaryBB.bottom, secondaryBB2.top, secondaryBB2.bottom)) + .toBeWithin(2, 1); + }) + .catch(failTest) + .then(done); }); }); diff --git a/test/jasmine/tests/svg_text_utils_test.js b/test/jasmine/tests/svg_text_utils_test.js index 3c49cd033d3..e6e41b201f4 100644 --- a/test/jasmine/tests/svg_text_utils_test.js +++ b/test/jasmine/tests/svg_text_utils_test.js @@ -468,4 +468,49 @@ describe('svg+text utils', function() { expect(node.text()).toEqual('test\u200b5\u200bmore'); }); }); + + describe('plainText:', function() { + var fn = util.plainText; + + it('should strip tags except
by default', function() { + expect(fn('ab
tma')).toBe('ab
tma'); + }); + + it('should work in various cases w/o
', function() { + var sIn = 'ThisIsDATA300'; + + expect(fn(sIn)).toBe('ThisIsDATA300'); + expect(fn(sIn, {len: 3})).toBe('Thi'); + expect(fn(sIn, {len: 4})).toBe('T...'); + expect(fn(sIn, {len: 13})).toBe('ThisIsDATA...'); + expect(fn(sIn, {len: 16})).toBe('ThisIsDATA300'); + expect(fn(sIn, {allowedTags: ['sup']})).toBe('ThisIsDATA300'); + expect(fn(sIn, {len: 13, allowedTags: ['sup']})).toBe('ThisIsDATA...'); + expect(fn(sIn, {len: 16, allowedTags: ['sup']})).toBe('ThisIsDATA300'); + }); + + it('should work in various cases w/
', function() { + var sIn = 'ThisIs
DATA300'; + + expect(fn(sIn)).toBe('ThisIs
DATA300'); + expect(fn(sIn, {len: 3})).toBe('Thi'); + expect(fn(sIn, {len: 4})).toBe('T...'); + expect(fn(sIn, {len: 7})).toBe('ThisIs...'); + expect(fn(sIn, {len: 8})).toBe('ThisIs...'); + expect(fn(sIn, {len: 9})).toBe('ThisIs...'); + expect(fn(sIn, {len: 10})).toBe('ThisIs
D...'); + expect(fn(sIn, {len: 13})).toBe('ThisIs
DATA...'); + expect(fn(sIn, {len: 16})).toBe('ThisIs
DATA300'); + expect(fn(sIn, {allowedTags: ['sup']})).toBe('ThisIsDATA300'); + expect(fn(sIn, {allowedTags: ['br', 'sup']})).toBe('ThisIs
DATA300'); + }); + + it('should work in various cases w/ , and ', function() { + var sIn = 'ThisIsDATA300'; + + expect(fn(sIn)).toBe('ThisIsDATA300'); + expect(fn(sIn, {allowedTags: ['i', 'b', 'em']})).toBe('ThisIsDATA300'); + expect(fn(sIn, {len: 10, allowedTags: ['i', 'b', 'em']})).toBe('ThisIsD...'); + }); + }); });