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('?(' + Object.keys(TAG_STYLES).join('|') + ')( [^>]*)?/?>', '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...');
+ });
+ });
});