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' };