diff --git a/src/components/fx/helpers.js b/src/components/fx/helpers.js
index 83846707fa7..ce1011edb2f 100644
--- a/src/components/fx/helpers.js
+++ b/src/components/fx/helpers.js
@@ -54,7 +54,7 @@ exports.p2c = function p2c(axArray, v) {
exports.getDistanceFunction = function getDistanceFunction(mode, dx, dy, dxy) {
if(mode === 'closest') return dxy || exports.quadrature(dx, dy);
- return mode === 'x' ? dx : dy;
+ return mode.charAt(0) === 'x' ? dx : dy;
};
exports.getClosest = function getClosest(cd, distfn, pointData) {
diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js
index b2bfc185213..8ec41867856 100644
--- a/src/components/fx/hover.js
+++ b/src/components/fx/hover.js
@@ -25,6 +25,9 @@ var Registry = require('../../registry');
var helpers = require('./helpers');
var constants = require('./constants');
+var legendSupplyDefaults = require('../legend/defaults');
+var legendDraw = require('../legend/draw');
+
// hover labels for multiple horizontal bars get tilted by some angle,
// then need to be offset differently if they overlap
var YANGLE = constants.YANGLE;
@@ -244,7 +247,7 @@ function _hover(gd, evt, subplot, noHoverEvent) {
if(hovermode && !supportsCompare) hovermode = 'closest';
- if(['x', 'y', 'closest'].indexOf(hovermode) === -1 || !gd.calcdata ||
+ if(['x', 'y', 'closest', 'x unified', 'y unified'].indexOf(hovermode) === -1 || !gd.calcdata ||
gd.querySelector('.zoombox') || gd._dragging) {
return dragElement.unhoverRaw(gd, evt);
}
@@ -388,6 +391,9 @@ function _hover(gd, evt, subplot, noHoverEvent) {
// within one trace mode can sometimes be overridden
mode = hovermode;
+ if(['x unified', 'y unified'].indexOf(mode) !== -1) {
+ mode = mode.charAt(0);
+ }
// container for new point, also used to pass info into module.hoverPoints
pointData = {
@@ -661,9 +667,10 @@ function _hover(gd, evt, subplot, noHoverEvent) {
var hoverLabels = createHoverText(hoverData, labelOpts, gd);
- hoverAvoidOverlaps(hoverLabels, rotateLabels ? 'xa' : 'ya', fullLayout);
-
- alignHoverText(hoverLabels, rotateLabels);
+ if(['x unified', 'y unified'].indexOf(hovermode) === -1) {
+ hoverAvoidOverlaps(hoverLabels, rotateLabels ? 'xa' : 'ya', fullLayout);
+ alignHoverText(hoverLabels, rotateLabels);
+ }
// TODO: tagName hack is needed to appease geo.js's hack of using evt.target=true
// we should improve the "fx" API so other plots can use it without these hack.
@@ -712,7 +719,7 @@ function createHoverText(hoverData, opts, gd) {
var c0 = hoverData[0];
var xa = c0.xa;
var ya = c0.ya;
- var commonAttr = hovermode === 'y' ? 'yLabel' : 'xLabel';
+ var commonAttr = hovermode.charAt(0) === 'y' ? 'yLabel' : 'xLabel';
var t0 = c0[commonAttr];
var t00 = (String(t0) || '').split(' ')[0];
var outerContainerBB = outerContainer.node().getBoundingClientRect();
@@ -906,11 +913,113 @@ function createHoverText(hoverData, opts, gd) {
// remove the "close but not quite" points
// because of error bars, only take up to a space
- hoverData = hoverData.filter(function(d) {
+ hoverData = filterClosePoints(hoverData);
+ });
+
+ function filterClosePoints(hoverData) {
+ return hoverData.filter(function(d) {
return (d.zLabelVal !== undefined) ||
(d[commonAttr] || '').split(' ')[0] === t00;
});
- });
+ }
+
+ // Show a single hover label
+ if(['x unified', 'y unified'].indexOf(hovermode) !== -1) {
+ // Delete leftover hover labels from other hovermodes
+ container.selectAll('g.hovertext').remove();
+
+ // similarly to compare mode, we remove the "close but not quite together" points
+ if((t0 !== undefined) && (c0.distance <= opts.hoverdistance)) hoverData = filterClosePoints(hoverData);
+
+ // Return early if nothing is hovered on
+ if(hoverData.length === 0) return;
+
+ // mock legend
+ var mockLayoutIn = {
+ showlegend: true,
+ legend: {
+ title: {text: t0, font: fullLayout.font},
+ font: fullLayout.font,
+ bgcolor: fullLayout.paper_bgcolor,
+ borderwidth: 1,
+ tracegroupgap: 7,
+ traceorder: fullLayout.legend ? fullLayout.legend.traceorder : undefined,
+ orientation: 'v'
+ }
+ };
+ var mockLayoutOut = {};
+ legendSupplyDefaults(mockLayoutIn, mockLayoutOut, gd._fullData);
+ var legendOpts = mockLayoutOut.legend;
+
+ // prepare items for the legend
+ legendOpts.entries = [];
+ for(var j = 0; j < hoverData.length; j++) {
+ var texts = getHoverLabelText(hoverData[j], true, hovermode, fullLayout, t0);
+ var text = texts[0];
+ var name = texts[1];
+ var pt = hoverData[j];
+ pt.name = name;
+ if(name !== '') {
+ pt.text = name + ' : ' + text;
+ } else {
+ pt.text = text;
+ }
+
+ // pass through marker's calcdata to style legend items
+ var cd = pt.cd[pt.index];
+ if(cd) {
+ if(cd.mc) pt.mc = cd.mc;
+ if(cd.mcc) pt.mc = cd.mcc;
+ if(cd.mlc) pt.mlc = cd.mlc;
+ if(cd.mlcc) pt.mlc = cd.mlcc;
+ if(cd.mlw) pt.mlw = cd.mlw;
+ if(cd.mrc) pt.mrc = cd.mrc;
+ if(cd.dir) pt.dir = cd.dir;
+ }
+ pt._distinct = true;
+
+ legendOpts.entries.push([pt]);
+ }
+ legendOpts.entries.sort(function(a, b) { return a[0].trace.index - b[0].trace.index;});
+ legendOpts.layer = container;
+
+ // Draw unified hover label
+ legendDraw(gd, legendOpts);
+
+ // Position the hover
+ var ly = Lib.mean(hoverData.map(function(c) {return (c.y0 + c.y1) / 2;}));
+ var lx = Lib.mean(hoverData.map(function(c) {return (c.x0 + c.x1) / 2;}));
+ var legendContainer = container.select('g.legend');
+ var tbb = legendContainer.node().getBoundingClientRect();
+ lx += xa._offset;
+ ly += ya._offset - tbb.height / 2;
+
+ // Change horizontal alignment to end up on screen
+ var txWidth = tbb.width + 2 * HOVERTEXTPAD;
+ var anchorStartOK = lx + txWidth <= outerWidth;
+ var anchorEndOK = lx - txWidth >= 0;
+ if(!anchorStartOK && anchorEndOK) {
+ lx -= txWidth;
+ } else {
+ lx += 2 * HOVERTEXTPAD;
+ }
+
+ // Change vertical alignement to end up on screen
+ var txHeight = tbb.height + 2 * HOVERTEXTPAD;
+ var overflowTop = ly <= outerTop;
+ var overflowBottom = ly + txHeight >= outerHeight;
+ var canFit = txHeight <= outerHeight;
+ if(canFit) {
+ if(overflowTop) {
+ ly = ya._offset + 2 * HOVERTEXTPAD;
+ } else if(overflowBottom) {
+ ly = outerHeight - txHeight;
+ }
+ }
+ legendContainer.attr('transform', 'translate(' + lx + ',' + ly + ')');
+
+ return legendContainer;
+ }
// show all the individual labels
@@ -941,8 +1050,6 @@ function createHoverText(hoverData, opts, gd) {
// and figure out sizes
hoverLabels.each(function(d) {
var g = d3.select(this).attr('transform', '');
- var name = '';
- var text = '';
// combine possible non-opaque trace color with bgColor
var color0 = d.bgcolor || d.color;
@@ -959,72 +1066,9 @@ function createHoverText(hoverData, opts, gd) {
// find a contrasting color for border and text
var contrastColor = d.borderColor || Color.contrast(numsColor);
- // to get custom 'name' labels pass cleanPoint
- if(d.nameOverride !== undefined) d.name = d.nameOverride;
-
- if(d.name) {
- if(d.trace._meta) {
- d.name = Lib.templateString(d.name, d.trace._meta);
- }
- name = plainText(d.name, d.nameLength);
- }
-
- if(d.zLabel !== undefined) {
- if(d.xLabel !== undefined) text += 'x: ' + d.xLabel + '
';
- if(d.yLabel !== undefined) text += 'y: ' + d.yLabel + '
';
- if(d.trace.type !== 'choropleth' && d.trace.type !== 'choroplethmapbox') {
- text += (text ? 'z: ' : '') + d.zLabel;
- }
- } else if(showCommonLabel && d[hovermode + 'Label'] === t0) {
- text = d[(hovermode === 'x' ? 'y' : 'x') + 'Label'] || '';
- } else if(d.xLabel === undefined) {
- if(d.yLabel !== undefined && d.trace.type !== 'scattercarpet') {
- text = d.yLabel;
- }
- } else if(d.yLabel === undefined) text = d.xLabel;
- else text = '(' + d.xLabel + ', ' + d.yLabel + ')';
-
- if((d.text || d.text === 0) && !Array.isArray(d.text)) {
- text += (text ? '
' : '') + d.text;
- }
-
- // used by other modules (initially just ternary) that
- // manage their own hoverinfo independent of cleanPoint
- // the rest of this will still apply, so such modules
- // can still put things in (x|y|z)Label, text, and name
- // and hoverinfo will still determine their visibility
- if(d.extraText !== undefined) text += (text ? '
' : '') + d.extraText;
-
- // if 'text' is empty at this point,
- // and hovertemplate is not defined,
- // put 'name' in main label and don't show secondary label
- if(text === '' && !d.hovertemplate) {
- // if 'name' is also empty, remove entire label
- if(name === '') g.remove();
- text = name;
- }
-
- // hovertemplate
- var d3locale = fullLayout._d3locale;
- var hovertemplate = d.hovertemplate || false;
- var hovertemplateLabels = d.hovertemplateLabels || d;
- var eventData = d.eventData[0] || {};
- if(hovertemplate) {
- text = Lib.hovertemplateString(
- hovertemplate,
- hovertemplateLabels,
- d3locale,
- eventData,
- d.trace._meta
- );
-
- text = text.replace(EXTRA_STRING_REGEX, function(match, extra) {
- // assign name for secondary text label
- name = plainText(extra, d.nameLength);
- // remove from main text label
- return '';
- });
- }
+ var texts = getHoverLabelText(d, showCommonLabel, hovermode, fullLayout, t0, g);
+ var text = texts[0];
+ var name = texts[1];
// main label
var tx = g.select('text.nums')
@@ -1123,6 +1167,78 @@ function createHoverText(hoverData, opts, gd) {
return hoverLabels;
}
+function getHoverLabelText(d, showCommonLabel, hovermode, fullLayout, t0, g) {
+ var name = '';
+ var text = '';
+ // to get custom 'name' labels pass cleanPoint
+ if(d.nameOverride !== undefined) d.name = d.nameOverride;
+
+ if(d.name) {
+ if(d.trace._meta) {
+ d.name = Lib.templateString(d.name, d.trace._meta);
+ }
+ name = plainText(d.name, d.nameLength);
+ }
+
+ if(d.zLabel !== undefined) {
+ if(d.xLabel !== undefined) text += 'x: ' + d.xLabel + '
';
+ if(d.yLabel !== undefined) text += 'y: ' + d.yLabel + '
';
+ if(d.trace.type !== 'choropleth' && d.trace.type !== 'choroplethmapbox') {
+ text += (text ? 'z: ' : '') + d.zLabel;
+ }
+ } else if(showCommonLabel && d[hovermode.charAt(0) + 'Label'] === t0) {
+ text = d[(hovermode.charAt(0) === 'x' ? 'y' : 'x') + 'Label'] || '';
+ } else if(d.xLabel === undefined) {
+ if(d.yLabel !== undefined && d.trace.type !== 'scattercarpet') {
+ text = d.yLabel;
+ }
+ } else if(d.yLabel === undefined) text = d.xLabel;
+ else text = '(' + d.xLabel + ', ' + d.yLabel + ')';
+
+ if((d.text || d.text === 0) && !Array.isArray(d.text)) {
+ text += (text ? '
' : '') + d.text;
+ }
+
+ // used by other modules (initially just ternary) that
+ // manage their own hoverinfo independent of cleanPoint
+ // the rest of this will still apply, so such modules
+ // can still put things in (x|y|z)Label, text, and name
+ // and hoverinfo will still determine their visibility
+ if(d.extraText !== undefined) text += (text ? '
' : '') + d.extraText;
+
+ // if 'text' is empty at this point,
+ // and hovertemplate is not defined,
+ // put 'name' in main label and don't show secondary label
+ if(g && text === '' && !d.hovertemplate) {
+ // if 'name' is also empty, remove entire label
+ if(name === '') g.remove();
+ text = name;
+ }
+
+ // hovertemplate
+ var d3locale = fullLayout._d3locale;
+ var hovertemplate = d.hovertemplate || false;
+ var hovertemplateLabels = d.hovertemplateLabels || d;
+ var eventData = d.eventData[0] || {};
+ if(hovertemplate) {
+ text = Lib.hovertemplateString(
+ hovertemplate,
+ hovertemplateLabels,
+ d3locale,
+ eventData,
+ d.trace._meta
+ );
+
+ text = text.replace(EXTRA_STRING_REGEX, function(match, extra) {
+ // assign name for secondary text label
+ name = plainText(extra, d.nameLength);
+ // remove from main text label
+ return '';
+ });
+ }
+ return [text, name];
+}
+
// Make groups of touching points, and within each group
// move each point so that no labels overlap, but the average
// label position is the same as it was before moving. Indicentally,
diff --git a/src/components/fx/layout_attributes.js b/src/components/fx/layout_attributes.js
index 2ade6b3d1ee..64935ed59fb 100644
--- a/src/components/fx/layout_attributes.js
+++ b/src/components/fx/layout_attributes.js
@@ -57,10 +57,20 @@ module.exports = {
hovermode: {
valType: 'enumerated',
role: 'info',
- values: ['x', 'y', 'closest', false],
+ values: ['x', 'y', 'closest', false, 'x unified', 'y unified'],
editType: 'modebar',
description: [
'Determines the mode of hover interactions.',
+ 'If *closest*, a single hoverlabel will appear',
+ 'for the *closest* point within the `hoverdistance`.',
+ 'If *x* (or *y*), multiple hoverlabels will appear for multiple points',
+ 'at the *closest* x- (or y-) coordinate within the `hoverdistance`,',
+ 'with the caveat that no more than one hoverlabel will appear per trace.',
+ 'If *x unified* (or *y unified*), a single hoverlabel will appear',
+ 'multiple points at the closest x- (or y-) coordinate within the `hoverdistance`',
+ 'with the caveat that no more than one hoverlabel will appear per trace.',
+ 'In this mode, spikelines are enabled by default perpendicular to the specified axis.',
+ 'If false, hover interactions are disabled.',
'If `clickmode` includes the *select* flag,',
'`hovermode` defaults to *closest*.',
'If `clickmode` lacks the *select* flag,',
diff --git a/src/components/fx/layout_defaults.js b/src/components/fx/layout_defaults.js
index 42941f83cba..70931e3e730 100644
--- a/src/components/fx/layout_defaults.js
+++ b/src/components/fx/layout_defaults.js
@@ -35,8 +35,12 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
var hoverMode = coerce('hovermode', hovermodeDflt);
if(hoverMode) {
+ var dflt;
+ if(['x unified', 'y unified'].indexOf(hoverMode) !== -1) {
+ dflt = -1;
+ }
coerce('hoverdistance');
- coerce('spikedistance');
+ coerce('spikedistance', dflt);
}
// if only mapbox or geo subplots is present on graph,
diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js
index c5610577de7..b9122e4ccef 100644
--- a/src/components/legend/draw.js
+++ b/src/components/legend/draw.js
@@ -30,26 +30,44 @@ var getLegendData = require('./get_legend_data');
var style = require('./style');
var helpers = require('./helpers');
-module.exports = function draw(gd) {
+module.exports = function draw(gd, opts) {
var fullLayout = gd._fullLayout;
var clipId = 'legend' + fullLayout._uid;
+ var layer;
- if(!fullLayout._infolayer || !gd.calcdata) return;
+ // Check whether this is the main legend (ie. called without any opts)
+ if(!opts) {
+ opts = fullLayout.legend || {};
+ opts._main = true;
+ layer = fullLayout._infolayer;
+ } else {
+ layer = opts.layer;
+ clipId += '-hover';
+ }
+
+ if(!layer) return;
if(!gd._legendMouseDownTime) gd._legendMouseDownTime = 0;
- var opts = fullLayout.legend;
- var legendData = fullLayout.showlegend && getLegendData(gd.calcdata, opts);
+ var legendData;
+ if(opts._main) {
+ if(!gd.calcdata) return;
+ legendData = fullLayout.showlegend && getLegendData(gd.calcdata, opts);
+ } else {
+ if(!opts.entries) return;
+ legendData = getLegendData(opts.entries, opts);
+ }
+
var hiddenSlices = fullLayout.hiddenlabels || [];
- if(!fullLayout.showlegend || !legendData.length) {
- fullLayout._infolayer.selectAll('.legend').remove();
+ if(opts._main && (!fullLayout.showlegend || !legendData.length)) {
+ layer.selectAll('.legend').remove();
fullLayout._topdefs.select('#' + clipId).remove();
return Plots.autoMargin(gd, 'legend');
}
- var legend = Lib.ensureSingle(fullLayout._infolayer, 'g', 'legend', function(s) {
- s.attr('pointer-events', 'all');
+ var legend = Lib.ensureSingle(layer, 'g', 'legend', function(s) {
+ if(opts._main) s.attr('pointer-events', 'all');
});
var clipPath = Lib.ensureSingleById(fullLayout._topdefs, 'clipPath', clipId, function(s) {
@@ -75,7 +93,7 @@ module.exports = function draw(gd) {
.call(Drawing.font, title.font)
.text(title.text);
- textLayout(titleEl, scrollBox, gd); // handle mathjax or multi-line text and compute title height
+ textLayout(titleEl, scrollBox, gd, opts); // handle mathjax or multi-line text and compute title height
}
var scrollBar = Lib.ensureSingle(legend, 'rect', 'scrollbar', function(s) {
@@ -99,18 +117,18 @@ module.exports = function draw(gd) {
return trace.visible === 'legendonly' ? 0.5 : 1;
}
})
- .each(function() { d3.select(this).call(drawTexts, gd); })
- .call(style, gd)
- .each(function() { d3.select(this).call(setupTraceToggle, gd); });
+ .each(function() { d3.select(this).call(drawTexts, gd, opts); })
+ .call(style, gd, opts)
+ .each(function() { if(opts._main) d3.select(this).call(setupTraceToggle, gd); });
Lib.syncOrAsync([
Plots.previousPromises,
- function() { return computeLegendDimensions(gd, groups, traces); },
+ function() { return computeLegendDimensions(gd, groups, traces, opts); },
function() {
// IF expandMargin return a Promise (which is truthy),
// we're under a doAutoMargin redraw, so we don't have to
// draw the remaining pieces below
- if(expandMargin(gd)) return;
+ if(opts._main && expandMargin(gd)) return;
var gs = fullLayout._size;
var bw = opts.borderwidth;
@@ -118,7 +136,7 @@ module.exports = function draw(gd) {
var lx = gs.l + gs.w * opts.x - FROM_TL[getXanchor(opts)] * opts._width;
var ly = gs.t + gs.h * (1 - opts.y) - FROM_TL[getYanchor(opts)] * opts._effHeight;
- if(fullLayout.margin.autoexpand) {
+ if(opts._main && fullLayout.margin.autoexpand) {
var lx0 = lx;
var ly0 = ly;
@@ -135,17 +153,22 @@ module.exports = function draw(gd) {
// Set size and position of all the elements that make up a legend:
// legend, background and border, scroll box and scroll bar as well as title
- Drawing.setTranslate(legend, lx, ly);
+ if(opts._main) Drawing.setTranslate(legend, lx, ly);
// to be safe, remove previous listeners
scrollBar.on('.drag', null);
legend.on('wheel', null);
- if(opts._height <= opts._maxHeight || gd._context.staticPlot) {
+ if(!opts._main || opts._height <= opts._maxHeight || gd._context.staticPlot) {
// if scrollbar should not be shown.
+ var height = opts._effHeight;
+
+ // if not the main legend, let it be its full size
+ if(!opts._main) height = opts._height;
+
bg.attr({
width: opts._width - bw,
- height: opts._effHeight - bw,
+ height: height - bw,
x: bw / 2,
y: bw / 2
});
@@ -154,7 +177,7 @@ module.exports = function draw(gd) {
clipPath.select('rect').attr({
width: opts._width - 2 * bw,
- height: opts._effHeight - 2 * bw,
+ height: height - 2 * bw,
x: bw,
y: bw
});
@@ -310,7 +333,7 @@ module.exports = function draw(gd) {
}
},
clickFn: function(numClicks, e) {
- var clickedTrace = fullLayout._infolayer.selectAll('g.traces').filter(function() {
+ var clickedTrace = layer.selectAll('g.traces').filter(function() {
var bbox = this.getBoundingClientRect();
return (
e.clientX >= bbox.left && e.clientX <= bbox.right &&
@@ -364,19 +387,22 @@ function clickOrDoubleClick(gd, legend, legendItem, numClicks, evt) {
}
}
-function drawTexts(g, gd) {
+function drawTexts(g, gd, opts) {
var legendItem = g.data()[0][0];
- var fullLayout = gd._fullLayout;
- var opts = fullLayout.legend;
var trace = legendItem.trace;
var isPieLike = Registry.traceIs(trace, 'pie-like');
var traceIndex = trace.index;
- var isEditable = gd._context.edits.legendText && !isPieLike;
+ var isEditable = opts._main && gd._context.edits.legendText && !isPieLike;
var maxNameLength = opts._maxNameLength;
- var name = isPieLike ? legendItem.label : trace.name;
- if(trace._meta) {
- name = Lib.templateString(name, trace._meta);
+ var name;
+ if(!opts.entries) {
+ name = isPieLike ? legendItem.label : trace.name;
+ if(trace._meta) {
+ name = Lib.templateString(name, trace._meta);
+ }
+ } else {
+ name = legendItem.text;
}
var textEl = Lib.ensureSingle(g, 'text', 'legendtext');
@@ -390,10 +416,10 @@ function drawTexts(g, gd) {
if(isEditable) {
textEl.call(svgTextUtils.makeEditable, {gd: gd, text: name})
- .call(textLayout, g, gd)
+ .call(textLayout, g, gd, opts)
.on('edit', function(newName) {
this.text(ensureLength(newName, maxNameLength))
- .call(textLayout, g, gd);
+ .call(textLayout, g, gd, opts);
var fullInput = legendItem.trace._fullInput || {};
var update = {};
@@ -414,7 +440,7 @@ function drawTexts(g, gd) {
return Registry.call('_guiRestyle', gd, update, traceIndex);
});
} else {
- textLayout(textEl, g, gd);
+ textLayout(textEl, g, gd, opts);
}
}
@@ -467,23 +493,24 @@ function setupTraceToggle(g, gd) {
});
}
-function textLayout(s, g, gd) {
+function textLayout(s, g, gd, opts) {
+ if(!opts._main) s.attr('data-notex', true); // do not process MathJax if not main
svgTextUtils.convertToTspans(s, gd, function() {
- computeTextDimensions(g, gd);
+ computeTextDimensions(g, gd, opts);
});
}
-function computeTextDimensions(g, gd) {
+function computeTextDimensions(g, gd, opts) {
var legendItem = g.data()[0][0];
- if(legendItem && !legendItem.trace.showlegend) {
+ if(opts._main && legendItem && !legendItem.trace.showlegend) {
g.remove();
return;
}
var mathjaxGroup = g.select('g[class*=math-group]');
var mathjaxNode = mathjaxGroup.node();
- var bw = gd._fullLayout.legend.borderwidth;
- var opts = gd._fullLayout.legend;
+ if(!opts) opts = gd._fullLayout.legend;
+ var bw = opts.borderwidth;
var lineHeight = (legendItem ? opts : opts.title).font.size * LINE_SPACING;
var height, width;
@@ -555,9 +582,9 @@ function getTitleSize(opts) {
* - _width: legend width
* - _maxWidth (for orientation:h only): maximum width before starting new row
*/
-function computeLegendDimensions(gd, groups, traces) {
+function computeLegendDimensions(gd, groups, traces, opts) {
var fullLayout = gd._fullLayout;
- var opts = fullLayout.legend;
+ if(!opts) opts = fullLayout.legend;
var gs = fullLayout._size;
var isVertical = helpers.isVertical(opts);
diff --git a/src/components/legend/get_legend_data.js b/src/components/legend/get_legend_data.js
index 71909d54210..abfca91ce4e 100644
--- a/src/components/legend/get_legend_data.js
+++ b/src/components/legend/get_legend_data.js
@@ -19,6 +19,7 @@ module.exports = function getLegendData(calcdata, opts) {
var lgroupi = 0;
var maxNameLength = 0;
var i, j;
+ var main = opts._main;
function addOneItem(legendGroup, legendItem) {
// each '' legend group is treated as a separate group
@@ -44,7 +45,7 @@ module.exports = function getLegendData(calcdata, opts) {
var trace = cd0.trace;
var lgroup = trace.legendgroup;
- if(!trace.visible || !trace.showlegend) continue;
+ if(main && (!trace.visible || !trace.showlegend)) continue;
if(Registry.traceIs(trace, 'pie-like')) {
if(!slicesShown[lgroup]) slicesShown[lgroup] = {};
diff --git a/src/components/legend/style.js b/src/components/legend/style.js
index 6525a34b995..b7b9bec7618 100644
--- a/src/components/legend/style.js
+++ b/src/components/legend/style.js
@@ -26,9 +26,9 @@ var CST_MARKER_LINE_WIDTH = 2;
var MAX_LINE_WIDTH = 10;
var MAX_MARKER_LINE_WIDTH = 5;
-module.exports = function style(s, gd) {
+module.exports = function style(s, gd, legend) {
var fullLayout = gd._fullLayout;
- var legend = fullLayout.legend;
+ if(!legend) legend = fullLayout.legend;
var constantItemSizing = legend.itemsizing === 'constant';
var boundLineWidth = function(mlw, cont, max, cst) {
@@ -213,7 +213,10 @@ module.exports = function style(s, gd) {
return valToBound;
}
- function pickFirst(array) { return array[0]; }
+ function pickFirst(array) {
+ if(d0._distinct && d0.index && array[d0.index]) return array[d0.index];
+ return array[0];
+ }
// constrain text, markers, etc so they'll fit on the legend
if(showMarkers || showText || showLines) {
@@ -287,9 +290,18 @@ module.exports = function style(s, gd) {
function styleWaterfalls(d) {
var trace = d[0].trace;
+ var isWaterfall = trace.type === 'waterfall';
+
+ if(d[0]._distinct && isWaterfall) {
+ var cont = d[0].trace[d[0].dir].marker;
+ d[0].mc = cont.color;
+ d[0].mlw = cont.line.width;
+ d[0].mlc = cont.line.color;
+ return styleBarLike(d, this, 'waterfall');
+ }
var ptsData = [];
- if(trace.visible && trace.type === 'waterfall') {
+ if(trace.visible && isWaterfall) {
ptsData = d[0].hasTotals ?
[['increasing', 'M-6,-6V6H0Z'], ['totals', 'M6,6H0L-6,-6H-0Z'], ['decreasing', 'M6,6V-6H0Z']] :
[['increasing', 'M-6,-6V6H6Z'], ['decreasing', 'M6,6V-6H-6Z']];
diff --git a/src/components/modebar/manage.js b/src/components/modebar/manage.js
index abf97f66944..884a3c2c3ad 100644
--- a/src/components/modebar/manage.js
+++ b/src/components/modebar/manage.js
@@ -85,6 +85,7 @@ function getButtonGroups(gd) {
var hasPolar = fullLayout._has('polar');
var hasSankey = fullLayout._has('sankey');
var allAxesFixed = areAllAxesFixed(fullLayout);
+ var hasUnifiedHoverLabel = ['x unified', 'y unified'].indexOf(fullLayout.hovermode) !== -1;
var groups = [];
@@ -146,7 +147,7 @@ function getButtonGroups(gd) {
if(hasCartesian) {
hoverGroup = ['toggleSpikelines', 'hoverClosestCartesian', 'hoverCompareCartesian'];
}
- if(hasNoHover(fullData)) {
+ if(hasNoHover(fullData) || hasUnifiedHoverLabel) {
hoverGroup = [];
}
diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js
index ef2d354966c..9495caa45fe 100644
--- a/src/plot_api/plot_api.js
+++ b/src/plot_api/plot_api.js
@@ -1737,6 +1737,10 @@ function _restyle(gd, aobj, traces) {
hovermode.set('y');
} else if(hovermode.get() === 'y') {
hovermode.set('x');
+ } else if(hovermode.get() === 'x unified') {
+ hovermode.set('y unified');
+ } else if(hovermode.get() === 'y unified') {
+ hovermode.set('x unified');
}
}
diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js
index 21c651c245e..cd2fe435a95 100644
--- a/src/plots/cartesian/layout_defaults.js
+++ b/src/plots/cartesian/layout_defaults.js
@@ -249,12 +249,14 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
handleTypeDefaults(axLayoutIn, axLayoutOut, coerce, defaultOptions);
handleAxisDefaults(axLayoutIn, axLayoutOut, coerce, defaultOptions, layoutOut);
- var spikecolor = coerce2('spikecolor');
- var spikethickness = coerce2('spikethickness');
- var spikedash = coerce2('spikedash');
- var spikemode = coerce2('spikemode');
+ var unifiedHover = layoutIn.hovermode && ['x unified', 'y unified'].indexOf(layoutIn.hovermode) !== -1;
+ var unifiedSpike = unifiedHover && axLetter === layoutIn.hovermode.charAt(0);
+ var spikecolor = coerce2('spikecolor', unifiedHover ? axLayoutOut.color : undefined);
+ var spikethickness = coerce2('spikethickness', unifiedHover ? 1.5 : undefined);
+ var spikedash = coerce2('spikedash', unifiedHover ? 'dot' : undefined);
+ var spikemode = coerce2('spikemode', unifiedHover ? 'across' : undefined);
var spikesnap = coerce2('spikesnap');
- var showSpikes = coerce('showspikes', !!spikecolor || !!spikethickness || !!spikedash || !!spikemode || !!spikesnap);
+ var showSpikes = coerce('showspikes', !!unifiedSpike || !!spikecolor || !!spikethickness || !!spikedash || !!spikemode || !!spikesnap);
if(!showSpikes) {
delete axLayoutOut.spikecolor;
diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js
index c26cd8a2067..5ae0084e391 100644
--- a/test/jasmine/tests/hover_label_test.js
+++ b/test/jasmine/tests/hover_label_test.js
@@ -3646,3 +3646,389 @@ describe('dragmode: false', function() {
.then(done);
});
});
+
+describe('hovermode: (x|y)unified', function() {
+ var gd;
+ var mock = {
+ 'data': [
+ {'y': [0, 3, 6, 4, 10, 2, 3, 5, 4, 0, 5]},
+ {'y': [0, 4, 7, 8, 10, 6, 3, 3, 4, 0, 5], }
+ ], 'layout': {'showlegend': false, 'hovermode': 'x unified'}};
+
+ beforeEach(function() {
+ gd = createGraphDiv();
+ });
+
+ afterEach(destroyGraphDiv);
+
+ function _hover(gd, opts) {
+ Fx.hover(gd, opts);
+ Lib.clearThrottle();
+ }
+
+ function assertElementCount(selector, size) {
+ var g = d3.selectAll(selector);
+ expect(g.size()).toBe(size);
+ }
+
+ function assertLabel(expectation) {
+ var hoverLayer = d3.select('g.hoverlayer');
+ var hover = hoverLayer.select('g.legend');
+ var title = hover.select('text.legendtitletext');
+ var traces = hover.selectAll('g.traces');
+
+ if(expectation.title) {
+ expect(title.text()).toBe(expectation.title);
+ }
+
+ expect(traces.size()).toBe(expectation.items.length, 'has the incorrect number of items');
+ traces.each(function(_, i) {
+ var e = d3.select(this);
+ expect(e.select('text').text()).toBe(expectation.items[i]);
+ });
+ }
+
+ function assertBgcolor(color) {
+ var hoverLayer = d3.select('g.hoverlayer');
+ var hover = hoverLayer.select('g.legend');
+ var bg = hover.select('rect.bg');
+ expect(bg.node().style.fill).toBe(color);
+ }
+
+ function assertSymbol(exp) {
+ var hoverLayer = d3.select('g.hoverlayer');
+ var hover = hoverLayer.select('g.legend');
+ var traces = hover.selectAll('g.traces');
+ expect(traces.size()).toBe(exp.length);
+
+ traces.each(function(d, i) {
+ var pts = d3.select(this).selectAll('g.legendpoints path');
+ pts.each(function() {
+ var node = d3.select(this).node();
+ expect(node.style.fill).toBe(exp[i][0], 'wrong fill for point ' + i);
+ expect(node.style.strokeWidth).toBe(exp[i][1], 'wrong stroke-width for point ' + i);
+ expect(node.style.stroke).toBe(exp[i][2], 'wrong stroke for point ' + i);
+ });
+ });
+ }
+
+ it('set smart defaults for spikeline in x unified', function(done) {
+ Plotly.newPlot(gd, [{y: [4, 6, 5]}], {'hovermode': 'x unified', 'xaxis': {'color': 'red'}})
+ .then(function(gd) {
+ expect(gd._fullLayout.hovermode).toBe('x unified');
+ var ax = gd._fullLayout.xaxis;
+ expect(ax.showspike).toBeTrue;
+ expect(ax.spikemode).toBe('across');
+ expect(ax.spikethickness).toBe(1.5);
+ expect(ax.spikedash).toBe('dot');
+ expect(ax.spikecolor).toBe('red');
+ expect(gd._fullLayout.yaxis.showspike).toBeFalse;
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('set smart defaults for spikeline in y unified', function(done) {
+ Plotly.newPlot(gd, [{y: [4, 6, 5]}], {'hovermode': 'y unified', 'yaxis': {'color': 'red'}})
+ .then(function(gd) {
+ expect(gd._fullLayout.hovermode).toBe('y unified');
+ var ax = gd._fullLayout.yaxis;
+ expect(ax.showspike).toBeTrue;
+ expect(ax.spikemode).toBe('across');
+ expect(ax.spikethickness).toBe(1.5);
+ expect(ax.spikedash).toBe('dot');
+ expect(ax.spikecolor).toBe('red');
+ expect(gd._fullLayout.yaxis.showspike).toBeFalse;
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('x unified should work for x/y cartesian traces', function(done) {
+ var mockCopy = Lib.extendDeep({}, mock);
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, { xval: 3 });
+
+ assertLabel({title: '3', items: ['trace 0 : 4', 'trace 1 : 8']});
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('y unified should work for x/y cartesian traces', function(done) {
+ var mockCopy = Lib.extendDeep({}, mock);
+ mockCopy.layout.hovermode = 'y unified';
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, { yval: 6 });
+
+ assertLabel({title: '6', items: ['trace 0 : 2', 'trace 1 : 5']});
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('x unified should work for x/y cartesian traces with legendgroup', function(done) {
+ var mockLegendGroup = require('@mocks/legendgroup.json');
+ var mockCopy = Lib.extendDeep({}, mockLegendGroup);
+ mockCopy.layout.hovermode = 'x unified';
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, { xval: 3 });
+
+ assertLabel({title: '3', items: [
+ 'trace 0 : 2',
+ 'trace 1 : median: 1',
+ 'trace 3 : 2',
+ 'trace 2 : 2',
+ 'trace 5 : 2',
+ 'trace 4 : 1'
+ ]});
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('shares filtering logic with compare mode x', function(done) {
+ var mock = require('@mocks/27.json');
+ var mockCopy = Lib.extendDeep({}, mock);
+
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, { xval: '2002' });
+ assertElementCount('g.hovertext', 2);
+
+ return Plotly.relayout(gd, 'hovermode', 'x unified');
+ })
+ .then(function() {
+ _hover(gd, { xval: '2002' });
+ assertLabel({title: '2002.042', items: [
+ 'Market income : 0.5537845',
+ 'Market incom... : 0.4420997'
+ ]});
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should have the same traceorder as the legend', function(done) {
+ var mock = require('@mocks/stacked_area.json');
+ var mockCopy = Lib.extendDeep({}, mock);
+ mockCopy.layout.hovermode = 'x unified';
+ var expectation = ['top : 1', 'middle : 6', 'bottom : 0'];
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, { xval: 3 });
+
+ assertLabel({title: '3', items: expectation});
+ return Plotly.relayout(gd, 'legend.traceorder', 'normal');
+ })
+ .then(function(gd) {
+ _hover(gd, { xval: 3 });
+
+ assertLabel({title: '3', items: expectation.reverse()});
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should order items based on trace index as in the legend', function(done) {
+ var mock = require('@mocks/29.json');
+ var mockCopy = Lib.extendDeep({}, mock);
+ mockCopy.layout.hovermode = 'x unified';
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, {curveNumber: 0});
+
+ assertLabel({title: 'Apr 13, 2014, 15:21:11', items: [
+ 'Outdoor (wun... : (Apr 13, 2014, 15:26:12, 69.4)',
+ '1st Floor (N... : (Apr 13, 2014, 15:21:15, 74.8)',
+ '2nd Floor (R... : 73.625',
+ 'Attic (Ardui... : (Apr 13, 2014, 15:26:34, 98.49)'
+ ]});
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should work for finance traces', function(done) {
+ var mockOhlc = require('@mocks/finance_multicategory.json');
+ var mockCopy = Lib.extendDeep({}, mockOhlc);
+ mockCopy.layout.hovermode = 'x unified';
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, {curveNumber: 0, pointNumber: 0});
+
+ assertLabel({title: 'Group 2 - b', items: [
+ 'ohlc : open: 12high: 17low: 9close: 13 ▲',
+ 'candlestick : open: 22high: 27low: 19close: 23 ▲'
+ ]});
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should work for "legend_horizontal_autowrap"', function(done) {
+ var mock = require('@mocks/legend_horizontal_autowrap.json');
+ var mockCopy = Lib.extendDeep({}, mock);
+ mockCopy.layout.hovermode = 'x unified';
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, {xval: 1});
+
+ assertElementCount('g.hoverlayer g.legend', 1);
+ assertElementCount('g.hoverlayer g.traces', 20);
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should style scatter symbols accordingly', function(done) {
+ var mock = require('@mocks/marker_colorscale_template.json');
+ var mockCopy = Lib.extendDeep({}, mock);
+ mockCopy.layout.hovermode = 'x unified';
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, {xval: 1});
+ assertLabel({title: '1', items: ['2']});
+ assertSymbol([['rgb(33, 145, 140)', '0px', '']]);
+ })
+ .then(function() {
+ _hover(gd, {xval: 2});
+ assertLabel({title: '2', items: ['3']});
+ assertSymbol([['rgb(253, 231, 37)', '0px', '']]);
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should style bar symbols accordingly', function(done) {
+ var mock = require('@mocks/bar-marker-line-colorscales.json');
+ var mockCopy = Lib.extendDeep({}, mock);
+ mockCopy.layout.hovermode = 'x unified';
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, {xval: 10});
+ assertLabel({title: '10', items: ['10']});
+ assertSymbol([['rgb(94, 216, 43)', '4px', 'rgb(197, 232, 190)']]);
+ })
+ .then(function() {
+ _hover(gd, {xval: 20});
+ assertLabel({title: '20', items: ['20']});
+ assertSymbol([['rgb(168, 140, 33)', '4px', 'rgb(111, 193, 115)']]);
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should style funnel symbols accordingly', function(done) {
+ var mock = require('@mocks/funnel_custom.json');
+ var mockCopy = Lib.extendDeep({}, mock);
+ mockCopy.layout.hovermode = 'x unified';
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, {xval: 1});
+ // assertLabel({title: 'B', items: ['asdf', 'asdf']});
+ assertSymbol([
+ ['rgb(0, 255, 0)', '0px', ''],
+ ['rgb(255, 255, 0)', '5px', 'rgb(0, 0, 127)']
+ ]);
+ })
+ .then(function() {
+ _hover(gd, {xval: 4});
+ // assertLabel({title: 'E', items: ['asdf', 'asdf']});
+ // assertSymbol([['rgb(168, 140, 33)', '4px', 'rgb(111, 193, 115)']]);
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should style waterfall symbols correctly', function(done) {
+ var mock = require('@mocks/waterfall_custom.json');
+ var mockCopy = Lib.extendDeep({}, mock);
+ mockCopy.layout.hovermode = 'x unified';
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, {xval: 4});
+ assertSymbol([
+ ['rgb(255, 65, 54)', '0px', '']
+ ]);
+ return Plotly.restyle(gd, {
+ 'decreasing.marker.line.width': 5,
+ 'decreasing.marker.line.color': 'violet'
+ });
+ })
+ .then(function(gd) {
+ _hover(gd, {xval: 4});
+ assertSymbol([
+ ['rgb(255, 65, 54)', '5px', 'rgb(238, 130, 238)']
+ ]);
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('label should have color of paper_bgcolor', function(done) {
+ var mockCopy = Lib.extendDeep({}, mock);
+ var bgcolor = 'rgb(15, 200, 85)';
+ mockCopy.layout.paper_bgcolor = bgcolor;
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, { xval: 3 });
+
+ assertBgcolor(bgcolor);
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should work with hovertemplate', function(done) {
+ var mockCopy = Lib.extendDeep({}, mock);
+ mockCopy.data[0].hovertemplate = 'hovertemplate: %{y:0.2f}';
+ mockCopy.data[1].hovertemplate = 'name%{x:0.2f} %{y:0.2f}';
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, { xval: 3 });
+
+ assertLabel({title: '3', items: [
+ 'trace 0 : hovertemplate: 4.00',
+ 'name : 3.00 8.00'
+ ]});
+
+ return Plotly.restyle(gd, 'hovertemplate', '%{y:0.2f}');
+ })
+ .then(function(gd) {
+ _hover(gd, { xval: 3 });
+
+ assertLabel({title: '3', items: [
+ '4.00',
+ '8.00'
+ ]});
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('on relayout, it deletes existing hover', function(done) {
+ var mockCopy = Lib.extendDeep({}, mock);
+ mockCopy.layout.hovermode = 'x';
+ Plotly.newPlot(gd, mockCopy)
+ .then(function(gd) {
+ _hover(gd, { xval: 3 });
+
+ assertElementCount('g.hovertext', 2);
+ assertElementCount('g.legend', 0);
+
+ return Plotly.relayout(gd, 'hovermode', 'x unified');
+ })
+ .then(function(gd) {
+ _hover(gd, { xval: 3 });
+
+ assertElementCount('g.hovertext', 0);
+ assertElementCount('g.legend', 1);
+ })
+ .catch(failTest)
+ .then(done);
+ });
+});
diff --git a/test/jasmine/tests/legend_test.js b/test/jasmine/tests/legend_test.js
index 4650578d3ae..b32e2620f4a 100644
--- a/test/jasmine/tests/legend_test.js
+++ b/test/jasmine/tests/legend_test.js
@@ -388,6 +388,7 @@ describe('legend getLegendData', function() {
}}]
];
opts = {
+ _main: true,
traceorder: 'normal'
};