diff --git a/build/ploticon.js b/build/ploticon.js index 84b86fb0aa6..4dd7accc816 100644 --- a/build/ploticon.js +++ b/build/ploticon.js @@ -114,5 +114,11 @@ module.exports = { 'path': 'm0 850l0-143 143 0 0 143-143 0z m286 0l0-143 143 0 0 143-143 0z m285 0l0-143 143 0 0 143-143 0z m286 0l0-143 143 0 0 143-143 0z m-857-286l0-143 143 0 0 143-143 0z m857 0l0-143 143 0 0 143-143 0z m-857-285l0-143 143 0 0 143-143 0z m857 0l0-143 143 0 0 143-143 0z m-857-286l0-143 143 0 0 143-143 0z m286 0l0-143 143 0 0 143-143 0z m285 0l0-143 143 0 0 143-143 0z m286 0l0-143 143 0 0 143-143 0z', 'ascent': 850, 'descent': -150 + }, + 'spikeline': { + 'width': 1000, + 'path': 'M512 409c0-57-46-104-103-104-57 0-104 47-104 104 0 57 47 103 104 103 57 0 103-46 103-103z m-327-39l92 0 0 92-92 0z m-185 0l92 0 0 92-92 0z m370-186l92 0 0 93-92 0z m0-184l92 0 0 92-92 0z', + 'ascent': 850, + 'descent': -150 } }; diff --git a/src/components/dragelement/unhover.js b/src/components/dragelement/unhover.js index 1905cdf6825..731b1470637 100644 --- a/src/components/dragelement/unhover.js +++ b/src/components/dragelement/unhover.js @@ -41,6 +41,8 @@ unhover.raw = function unhoverRaw(gd, evt) { } fullLayout._hoverlayer.selectAll('g').remove(); + fullLayout._hoverlayer.selectAll('line').remove(); + fullLayout._hoverlayer.selectAll('circle').remove(); gd._hoverdata = undefined; if(evt.target && oldhoverdata) { diff --git a/src/components/drawing/attributes.js b/src/components/drawing/attributes.js new file mode 100644 index 00000000000..0ea5dbe3620 --- /dev/null +++ b/src/components/drawing/attributes.js @@ -0,0 +1,26 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +exports.dash = { + valType: 'string', + // string type usually doesn't take values... this one should really be + // a special type or at least a special coercion function, from the GUI + // you only get these values but elsewhere the user can supply a list of + // dash lengths in px, and it will be honored + values: ['solid', 'dot', 'dash', 'longdash', 'dashdot', 'longdashdot'], + dflt: 'solid', + role: 'style', + description: [ + 'Sets the dash style of lines. Set to a dash type string', + '(*solid*, *dot*, *dash*, *longdash*, *dashdot*, or *longdashdot*)', + 'or a dash length list in px (eg *5px,10px,2px,2px*).' + ].join(' ') +}; diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index fa995b0641e..67245728b47 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -113,6 +113,17 @@ drawing.lineGroupStyle = function(s, lw, lc, ld) { drawing.dashLine = function(s, dash, lineWidth) { lineWidth = +lineWidth || 0; + + dash = drawing.dashStyle(dash, lineWidth); + + s.style({ + 'stroke-dasharray': dash, + 'stroke-width': lineWidth + 'px' + }); +}; + +drawing.dashStyle = function(dash, lineWidth) { + lineWidth = +lineWidth || 1; var dlw = Math.max(lineWidth, 3); if(dash === 'solid') dash = ''; @@ -127,10 +138,7 @@ drawing.dashLine = function(s, dash, lineWidth) { } // otherwise user wrote the dasharray themselves - leave it be - s.style({ - 'stroke-dasharray': dash, - 'stroke-width': lineWidth + 'px' - }); + return dash; }; drawing.fillGroupStyle = function(s) { diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index aae0503e368..776a75df610 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -177,17 +177,20 @@ function handleCartesian(gd, ev) { astr = button.getAttribute('data-attr'), val = button.getAttribute('data-val') || true, fullLayout = gd._fullLayout, - aobj = {}; + aobj = {}, + axList = Axes.list(gd, null, true), + ax, + allEnabled = 'on', + i; if(astr === 'zoom') { var mag = (val === 'in') ? 0.5 : 2, r0 = (1 + mag) / 2, - r1 = (1 - mag) / 2, - axList = Axes.list(gd, null, true); + r1 = (1 - mag) / 2; - var ax, axName; + var axName; - for(var i = 0; i < axList.length; i++) { + for(i = 0; i < axList.length; i++) { ax = axList[i]; if(!ax.fixedrange) { @@ -202,6 +205,12 @@ function handleCartesian(gd, ev) { aobj[axName + '.range[0]'] = rangeInitial[0]; aobj[axName + '.range[1]'] = rangeInitial[1]; } + if(ax._showSpikeInitial !== undefined) { + aobj[axName + '.showspikes'] = ax._showSpikeInitial; + if(allEnabled === 'on' && !ax._showSpikeInitial) { + allEnabled = 'off'; + } + } } else { var rangeNow = [ @@ -219,12 +228,24 @@ function handleCartesian(gd, ev) { } } } + fullLayout._cartesianSpikesEnabled = allEnabled; } else { // if ALL traces have orientation 'h', 'hovermode': 'x' otherwise: 'y' if(astr === 'hovermode' && (val === 'x' || val === 'y')) { val = fullLayout._isHoriz ? 'y' : 'x'; button.setAttribute('data-val', val); + if(val !== 'closest') { + fullLayout._cartesianSpikesEnabled = 'off'; + } + } else if(astr === 'hovermode' && val === 'closest') { + for(i = 0; i < axList.length; i++) { + ax = axList[i]; + if(allEnabled === 'on' && !ax.showspikes) { + allEnabled = 'off'; + } + } + fullLayout._cartesianSpikesEnabled = allEnabled; } aobj[astr] = val; @@ -518,3 +539,38 @@ modeBarButtons.resetViews = { // geo subplots. } }; + +modeBarButtons.toggleSpikelines = { + name: 'toggleSpikelines', + title: 'Toggle Spike Lines', + icon: Icons.spikeline, + attr: '_cartesianSpikesEnabled', + val: 'on', + click: function(gd) { + var fullLayout = gd._fullLayout; + + fullLayout._cartesianSpikesEnabled = fullLayout.hovermode === 'closest' ? + (fullLayout._cartesianSpikesEnabled === 'on' ? 'off' : 'on') : 'on'; + + var aobj = setSpikelineVisibility(gd); + + aobj.hovermode = 'closest'; + Plotly.relayout(gd, aobj); + } +}; + +function setSpikelineVisibility(gd) { + var fullLayout = gd._fullLayout, + axList = Axes.list(gd, null, true), + ax, + axName, + aobj = {}; + + for(var i = 0; i < axList.length; i++) { + ax = axList[i]; + axName = ax._name; + aobj[axName + '.showspikes'] = fullLayout._cartesianSpikesEnabled === 'on' ? true : false; + } + + return aobj; +} diff --git a/src/components/modebar/manage.js b/src/components/modebar/manage.js index 57e5e2d17b3..f3732850f8c 100644 --- a/src/components/modebar/manage.js +++ b/src/components/modebar/manage.js @@ -138,7 +138,7 @@ function getButtonGroups(gd, buttonsToRemove, buttonsToAdd) { addGroup(['hoverClosestGl2d']); } else if(hasCartesian) { - addGroup(['hoverClosestCartesian', 'hoverCompareCartesian']); + addGroup(['toggleSpikelines', 'hoverClosestCartesian', 'hoverCompareCartesian']); } else if(hasPie) { addGroup(['hoverClosestPie']); diff --git a/src/components/modebar/modebar.js b/src/components/modebar/modebar.js index 054a8a91928..5e87b2413a8 100644 --- a/src/components/modebar/modebar.js +++ b/src/components/modebar/modebar.js @@ -149,7 +149,7 @@ proto.createButton = function(config) { button.setAttribute('data-toggle', config.toggle || false); if(config.toggle) d3.select(button).classed('active', true); - button.appendChild(this.createIcon(config.icon || Icons.question)); + button.appendChild(this.createIcon(config.icon || Icons.question, config.name)); button.setAttribute('data-gravity', config.gravity || 'n'); return button; @@ -162,7 +162,7 @@ proto.createButton = function(config) { * @Param {string} thisIcon.path * @Return {HTMLelement} */ -proto.createIcon = function(thisIcon) { +proto.createIcon = function(thisIcon, name) { var iconHeight = thisIcon.ascent - thisIcon.descent, svgNS = 'http://www.w3.org/2000/svg', icon = document.createElementNS(svgNS, 'svg'), @@ -172,8 +172,12 @@ proto.createIcon = function(thisIcon) { icon.setAttribute('width', (thisIcon.width / iconHeight) + 'em'); icon.setAttribute('viewBox', [0, 0, thisIcon.width, iconHeight].join(' ')); + var transform = name === 'toggleSpikelines' ? + 'matrix(1.5 0 0 -1.5 0 ' + thisIcon.ascent + ')' : + 'matrix(1 0 0 -1 0 ' + thisIcon.ascent + ')'; + path.setAttribute('d', thisIcon.path); - path.setAttribute('transform', 'matrix(1 0 0 -1 0 ' + thisIcon.ascent + ')'); + path.setAttribute('transform', transform); icon.appendChild(path); return icon; diff --git a/src/components/shapes/attributes.js b/src/components/shapes/attributes.js index a8974f2825e..83407b34067 100644 --- a/src/components/shapes/attributes.js +++ b/src/components/shapes/attributes.js @@ -9,11 +9,10 @@ 'use strict'; var annAttrs = require('../annotations/attributes'); -var scatterAttrs = require('../../traces/scatter/attributes'); +var scatterLineAttrs = require('../../traces/scatter/attributes').line; +var dash = require('../drawing/attributes').dash; var extendFlat = require('../../lib/extend').extendFlat; -var scatterLineAttrs = scatterAttrs.line; - module.exports = { _isLinkedToArray: 'shape', @@ -151,7 +150,7 @@ module.exports = { line: { color: scatterLineAttrs.color, width: scatterLineAttrs.width, - dash: scatterLineAttrs.dash, + dash: dash, role: 'info' }, fillcolor: { diff --git a/src/fonts/ploticon/ploticon.svg b/src/fonts/ploticon/ploticon.svg index a72ac5b551d..5007169983b 100644 --- a/src/fonts/ploticon/ploticon.svg +++ b/src/fonts/ploticon/ploticon.svg @@ -25,6 +25,7 @@ + - \ No newline at end of file + diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 2494c9d76e2..e712a42047c 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -153,6 +153,9 @@ Plotly.plot = function(gd, data, layout, config) { makePlotFramework(gd); } + // save initial show spikes once per graph + if(graphWasEmpty) Plotly.Axes.saveShowSpikeInitial(gd); + // prepare the data and find the autorange // generate calcdata, if we need to @@ -2121,14 +2124,16 @@ function _relayout(gd, aobj) { flags.doticks = flags.dolayoutstyle = true; } /* - * hovermode and dragmode don't need any redrawing, since they just - * affect reaction to user input, everything else, assume full replot. + * hovermode, dragmode, and spikes don't need any redrawing, since they just + * affect reaction to user input. Everything else, assume full replot. * height, width, autosize get dealt with below. Except for the case of * of subplots - scenes - which require scene.updateFx to be called. */ - else if(['hovermode', 'dragmode'].indexOf(ai) !== -1) flags.domodebar = true; - else if(['hovermode', 'dragmode', 'height', - 'width', 'autosize'].indexOf(ai) === -1) { + else if(['hovermode', 'dragmode'].indexOf(ai) !== -1 || + ai.indexOf('spike') !== -1) { + flags.domodebar = true; + } + else if(['height', 'width', 'autosize'].indexOf(ai) === -1) { flags.doplot = true; } diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index e77bc52831e..7903c792782 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -361,6 +361,35 @@ axes.saveRangeInitial = function(gd, overwrite) { return hasOneAxisChanged; }; +// save a copy of the initial spike visibility +axes.saveShowSpikeInitial = function(gd, overwrite) { + var axList = axes.list(gd, '', true), + hasOneAxisChanged = false, + allEnabled = 'on'; + + for(var i = 0; i < axList.length; i++) { + var ax = axList[i]; + + var isNew = (ax._showSpikeInitial === undefined); + var hasChanged = ( + isNew || !( + ax.showspikes === ax._showspikes + ) + ); + + if((isNew) || (overwrite && hasChanged)) { + ax._showSpikeInitial = ax.showspikes; + hasOneAxisChanged = true; + } + + if(allEnabled === 'on' && !ax.showspikes) { + allEnabled = 'off'; + } + } + gd._fullLayout._cartesianSpikesEnabled = allEnabled; + return hasOneAxisChanged; +}; + // axes.expand: if autoranging, include new data in the outer limits // for this axis // data is an array of numbers (ie already run through ax.d2c) @@ -1603,7 +1632,7 @@ axes.doTicks = function(gd, axid, skipTitle) { // set scaling to pixels ax.setScale(); - var axletter = axid.charAt(0), + var axLetter = axid.charAt(0), counterLetter = axes.counterLetter(axid), vals = axes.calcTicks(ax), datafn = function(d) { return [d.text, d.x, ax.mirror].join('_'); }, @@ -1617,7 +1646,7 @@ axes.doTicks = function(gd, axid, skipTitle) { gridWidth = Drawing.crispRound(gd, ax.gridwidth, 1), zeroLineWidth = Drawing.crispRound(gd, ax.zerolinewidth, gridWidth), tickWidth = Drawing.crispRound(gd, ax.tickwidth, 1), - sides, transfn, tickpathfn, + sides, transfn, tickpathfn, subplots, i; if(ax._counterangle && ax.ticks === 'outside') { @@ -1627,7 +1656,7 @@ axes.doTicks = function(gd, axid, skipTitle) { } // positioning arguments for x vs y axes - if(axletter === 'x') { + if(axLetter === 'x') { sides = ['bottom', 'top']; transfn = function(d) { return 'translate(' + ax.l2p(d.x) + ',0)'; @@ -1640,7 +1669,7 @@ axes.doTicks = function(gd, axid, skipTitle) { else return 'M0,' + shift + 'v' + len; }; } - else if(axletter === 'y') { + else if(axLetter === 'y') { sides = ['left', 'right']; transfn = function(d) { return 'translate(0,' + ax.l2p(d.x) + ')'; @@ -1661,7 +1690,7 @@ axes.doTicks = function(gd, axid, skipTitle) { // which direction do the side[0], side[1], and free ticks go? // then we flip if outside XOR y axis ticksign = [-1, 1, axside === sides[1] ? 1 : -1]; - if((ax.ticks !== 'inside') === (axletter === 'x')) { + if((ax.ticks !== 'inside') === (axLetter === 'x')) { ticksign = ticksign.map(function(v) { return -v; }); } @@ -1695,12 +1724,12 @@ axes.doTicks = function(gd, axid, skipTitle) { var tickLabels = container.selectAll('g.' + tcls).data(vals, datafn); if(!ax.showticklabels || !isNumeric(position)) { tickLabels.remove(); - drawAxTitle(axid); + drawAxTitle(); return; } var labelx, labely, labelanchor, labelpos0, flipit; - if(axletter === 'x') { + if(axLetter === 'x') { flipit = (axside === 'bottom') ? 1 : -1; labelx = function(d) { return d.dx + labelShift * flipit; }; labelpos0 = position + (labelStandoff + pad) * flipit; @@ -1816,7 +1845,7 @@ axes.doTicks = function(gd, axid, skipTitle) { // check for auto-angling if x labels overlap // don't auto-angle at all for log axes with // base and digit format - if(axletter === 'x' && !isNumeric(ax.tickangle) && + if(axLetter === 'x' && !isNumeric(ax.tickangle) && (ax.type !== 'log' || String(ax.dtick).charAt(0) !== 'D')) { var lbbArray = []; tickLabels.each(function(d) { @@ -1861,12 +1890,59 @@ axes.doTicks = function(gd, axid, skipTitle) { // (so it can move out of the way if needed) // TODO: separate out scoot so we don't need to do // a full redraw of the title (mostly relevant for MathJax) - drawAxTitle(axid); + drawAxTitle(); return axid + ' done'; } function calcBoundingBox() { - ax._boundingBox = container.node().getBoundingClientRect(); + var bBox = container.node().getBoundingClientRect(); + var gdBB = gd.getBoundingClientRect(); + + /* + * the way we're going to use this, the positioning that matters + * is relative to the origin of gd. This is important particularly + * if gd is scrollable, and may have been scrolled between the time + * we calculate this and the time we use it + */ + ax._boundingBox = { + width: bBox.width, + height: bBox.height, + left: bBox.left - gdBB.left, + right: bBox.right - gdBB.left, + top: bBox.top - gdBB.top, + bottom: bBox.bottom - gdBB.top + }; + + /* + * for spikelines: what's the full domain of positions in the + * opposite direction that are associated with this axis? + * This means any axes that we make a subplot with, plus the + * position of the axis itself if it's free. + */ + if(subplots) { + var fullRange = ax._counterSpan = [Infinity, -Infinity]; + + for(i = 0; i < subplots.length; i++) { + var subplot = fullLayout._plots[subplots[i]]; + var counterAxis = subplot[(axLetter === 'x') ? 'yaxis' : 'xaxis']; + + extendRange(fullRange, [ + counterAxis._offset, + counterAxis._offset + counterAxis._length + ]); + } + + if(ax.anchor === 'free') { + extendRange(fullRange, (axLetter === 'x') ? + [ax._boundingBox.bottom, ax._boundingBox.top] : + [ax._boundingBox.right, ax._boundingBox.left]); + } + } + + function extendRange(range, newRange) { + range[0] = Math.min(range[0], newRange[0]); + range[1] = Math.max(range[1], newRange[1]); + } } var done = Lib.syncOrAsync([ @@ -1878,7 +1954,7 @@ axes.doTicks = function(gd, axid, skipTitle) { return done; } - function drawAxTitle(axid) { + function drawAxTitle() { if(skipTitle) return; // now this only applies to regular cartesian axes; colorbars and @@ -1949,16 +2025,16 @@ axes.doTicks = function(gd, axid, skipTitle) { function traceHasBarsOrFill(trace, subplot) { if(trace.visible !== true || trace.xaxis + trace.yaxis !== subplot) return false; - if(Registry.traceIs(trace, 'bar') && trace.orientation === {x: 'h', y: 'v'}[axletter]) return true; - return trace.fill && trace.fill.charAt(trace.fill.length - 1) === axletter; + if(Registry.traceIs(trace, 'bar') && trace.orientation === {x: 'h', y: 'v'}[axLetter]) return true; + return trace.fill && trace.fill.charAt(trace.fill.length - 1) === axLetter; } function drawGrid(plotinfo, counteraxis, subplot) { var gridcontainer = plotinfo.gridlayer, zlcontainer = plotinfo.zerolinelayer, - gridvals = plotinfo['hidegrid' + axletter] ? [] : valsClipped, + gridvals = plotinfo['hidegrid' + axLetter] ? [] : valsClipped, gridpath = ax._gridpath || - 'M0,0' + ((axletter === 'x') ? 'v' : 'h') + counteraxis._length, + 'M0,0' + ((axLetter === 'x') ? 'v' : 'h') + counteraxis._length, grid = gridcontainer.selectAll('path.' + gcls) .data((ax.showgrid === false) ? [] : gridvals, datafn); grid.enter().append('path').classed(gcls, 1) @@ -2012,12 +2088,13 @@ axes.doTicks = function(gd, axid, skipTitle) { return drawLabels(ax._axislayer, ax._pos); } else { - var alldone = axes.getSubplots(gd, ax).map(function(subplot) { + subplots = axes.getSubplots(gd, ax); + var alldone = subplots.map(function(subplot) { var plotinfo = fullLayout._plots[subplot]; if(!fullLayout._has('cartesian')) return; - var container = plotinfo[axletter + 'axislayer'], + var container = plotinfo[axLetter + 'axislayer'], // [bottom or left, top or right, free, main] linepositions = ax._linepositions[subplot] || [], diff --git a/src/plots/cartesian/graph_interact.js b/src/plots/cartesian/graph_interact.js index d1307a8cbb5..9265753e9d5 100644 --- a/src/plots/cartesian/graph_interact.js +++ b/src/plots/cartesian/graph_interact.js @@ -11,6 +11,7 @@ var d3 = require('d3'); var isNumeric = require('fast-isnumeric'); +var tinycolor = require('tinycolor2'); var Lib = require('../../lib'); var Events = require('../../lib/events'); @@ -555,30 +556,8 @@ function hover(gd, evt, subplot) { // nothing left: remove all labels and quit if(hoverData.length === 0) return dragElement.unhoverRaw(gd, evt); - // if there's more than one horz bar trace, - // rotate the labels so they don't overlap - var rotateLabels = hovermode === 'y' && searchData.length > 1; - hoverData.sort(function(d1, d2) { return d1.distance - d2.distance; }); - var bgColor = Color.combine( - fullLayout.plot_bgcolor || Color.background, - fullLayout.paper_bgcolor - ); - - var labelOpts = { - hovermode: hovermode, - rotateLabels: rotateLabels, - bgColor: bgColor, - container: fullLayout._hoverlayer, - outerContainer: fullLayout._paperdiv - }; - var hoverLabels = createHoverText(hoverData, labelOpts); - - hoverAvoidOverlaps(hoverData, rotateLabels ? 'xa' : 'ya'); - - alignHoverText(hoverLabels, rotateLabels); - // lastly, emit custom hover/unhover events var oldhoverdata = gd._hoverdata, newhoverdata = []; @@ -610,6 +589,39 @@ function hover(gd, evt, subplot) { gd._hoverdata = newhoverdata; + if(hoverChanged(gd, evt, oldhoverdata) && fullLayout._hasCartesian) { + var spikelineOpts = { + hovermode: hovermode, + fullLayout: fullLayout, + container: fullLayout._hoverlayer, + outerContainer: fullLayout._paperdiv + }; + createSpikelines(hoverData, spikelineOpts); + } + + // if there's more than one horz bar trace, + // rotate the labels so they don't overlap + var rotateLabels = hovermode === 'y' && searchData.length > 1; + + var bgColor = Color.combine( + fullLayout.plot_bgcolor || Color.background, + fullLayout.paper_bgcolor + ); + + var labelOpts = { + hovermode: hovermode, + rotateLabels: rotateLabels, + bgColor: bgColor, + container: fullLayout._hoverlayer, + outerContainer: fullLayout._paperdiv + }; + + var hoverLabels = createHoverText(hoverData, labelOpts); + + hoverAvoidOverlaps(hoverData, rotateLabels ? 'xa' : 'ya'); + + 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. if(evt.target && evt.target.tagName) { @@ -617,7 +629,8 @@ function hover(gd, evt, subplot) { overrideCursor(d3.select(evt.target), hasClickToShow ? 'pointer' : ''); } - if(!hoverChanged(gd, evt, oldhoverdata)) return; + // don't emit events if called manually + if(!evt.target || !hoverChanged(gd, evt, oldhoverdata)) return; if(oldhoverdata) { gd.emit('plotly_unhover', { @@ -837,8 +850,142 @@ fx.loneUnhover = function(containerOrSelection) { d3.select(containerOrSelection); selection.selectAll('g.hovertext').remove(); + selection.selectAll('.spikeline').remove(); }; +function createSpikelines(hoverData, opts) { + var hovermode = opts.hovermode; + var container = opts.container; + var c0 = hoverData[0]; + var xa = c0.xa; + var ya = c0.ya; + var showX = xa.showspikes; + var showY = ya.showspikes; + + // Remove old spikeline items + container.selectAll('.spikeline').remove(); + + if(hovermode !== 'closest' || !(showX || showY)) return; + + var fullLayout = opts.fullLayout; + var xPoint = xa._offset + (c0.x0 + c0.x1) / 2; + var yPoint = ya._offset + (c0.y0 + c0.y1) / 2; + var contrastColor = Color.combine(fullLayout.plot_bgcolor, fullLayout.paper_bgcolor); + var dfltDashColor = tinycolor.readability(c0.color, contrastColor) < 1.5 ? + Color.contrast(contrastColor) : c0.color; + + if(showY) { + var yMode = ya.spikemode; + var yThickness = ya.spikethickness; + var yColor = ya.spikecolor || dfltDashColor; + var yBB = ya._boundingBox; + var xEdge = ((yBB.left + yBB.right) / 2) < xPoint ? yBB.right : yBB.left; + + if(yMode.indexOf('toaxis') !== -1 || yMode.indexOf('across') !== -1) { + var xBase = xEdge; + var xEndSpike = xPoint; + if(yMode.indexOf('across') !== -1) { + xBase = ya._counterSpan[0]; + xEndSpike = ya._counterSpan[1]; + } + + // Background horizontal Line (to y-axis) + container.append('line') + .attr({ + 'x1': xBase, + 'x2': xEndSpike, + 'y1': yPoint, + 'y2': yPoint, + 'stroke-width': yThickness + 2, + 'stroke': contrastColor + }) + .classed('spikeline', true) + .classed('crisp', true); + + // Foreground horizontal line (to y-axis) + container.append('line') + .attr({ + 'x1': xBase, + 'x2': xEndSpike, + 'y1': yPoint, + 'y2': yPoint, + 'stroke-width': yThickness, + 'stroke': yColor, + 'stroke-dasharray': Drawing.dashStyle(ya.spikedash, yThickness) + }) + .classed('spikeline', true) + .classed('crisp', true); + } + // Y axis marker + if(yMode.indexOf('marker') !== -1) { + container.append('circle') + .attr({ + 'cx': xEdge + (ya.side !== 'right' ? yThickness : -yThickness), + 'cy': yPoint, + 'r': yThickness, + 'fill': yColor + }) + .classed('spikeline', true); + } + } + + if(showX) { + var xMode = xa.spikemode; + var xThickness = xa.spikethickness; + var xColor = xa.spikecolor || dfltDashColor; + var xBB = xa._boundingBox; + var yEdge = ((xBB.top + xBB.bottom) / 2) < yPoint ? xBB.bottom : xBB.top; + + if(xMode.indexOf('toaxis') !== -1 || xMode.indexOf('across') !== -1) { + var yBase = yEdge; + var yEndSpike = yPoint; + if(xMode.indexOf('across') !== -1) { + yBase = xa._counterSpan[0]; + yEndSpike = xa._counterSpan[1]; + } + + // Background vertical line (to x-axis) + container.append('line') + .attr({ + 'x1': xPoint, + 'x2': xPoint, + 'y1': yBase, + 'y2': yEndSpike, + 'stroke-width': xThickness + 2, + 'stroke': contrastColor + }) + .classed('spikeline', true) + .classed('crisp', true); + + // Foreground vertical line (to x-axis) + container.append('line') + .attr({ + 'x1': xPoint, + 'x2': xPoint, + 'y1': yBase, + 'y2': yEndSpike, + 'stroke-width': xThickness, + 'stroke': xColor, + 'stroke-dasharray': Drawing.dashStyle(xa.spikedash, xThickness) + }) + .classed('spikeline', true) + .classed('crisp', true); + } + + // X axis marker + if(xMode.indexOf('marker') !== -1) { + container.append('circle') + .attr({ + 'cx': xPoint, + 'cy': yEdge - (xa.side !== 'top' ? xThickness : -xThickness), + 'r': xThickness, + 'fill': xColor + }) + .classed('spikeline', true); + } + } +} + function createHoverText(hoverData, opts) { var hovermode = opts.hovermode, rotateLabels = opts.rotateLabels, @@ -1359,9 +1506,7 @@ function alignHoverText(hoverLabels, rotateLabels) { } function hoverChanged(gd, evt, oldhoverdata) { - // don't emit any events if nothing changed or - // if fx.hover was called manually - if(!evt.target) return false; + // don't emit any events if nothing changed if(!oldhoverdata || oldhoverdata.length !== gd._hoverdata.length) return true; for(var i = oldhoverdata.length - 1; i >= 0; i--) { diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index f826ca522ba..f85f3bdf551 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -10,6 +10,7 @@ var fontAttrs = require('../font_attributes'); var colorAttrs = require('../../components/color/attributes'); +var dash = require('../../components/drawing/attributes').dash; var extendFlat = require('../../lib/extend').extendFlat; var constants = require('./constants'); @@ -279,6 +280,45 @@ module.exports = { role: 'style', description: 'Determines whether or not the tick labels are drawn.' }, + showspikes: { + valType: 'boolean', + dflt: false, + role: 'style', + description: [ + 'Determines whether or not spikes (aka droplines) are drawn for this axis.', + 'Note: This only takes affect when hovermode = closest' + ].join(' ') + }, + spikecolor: { + valType: 'color', + dflt: null, + role: 'style', + description: 'Sets the spike color. If undefined, will use the series color' + }, + spikethickness: { + valType: 'number', + dflt: 3, + role: 'style', + description: 'Sets the width (in px) of the zero line.' + }, + spikedash: extendFlat({}, dash, {dflt: 'dash'}), + spikemode: { + valType: 'flaglist', + flags: ['toaxis', 'across', 'marker'], + role: 'style', + dflt: 'toaxis', + description: [ + 'Determines the drawing mode for the spike line', + 'If *toaxis*, the line is drawn from the data point to the axis the ', + 'series is plotted on.', + + 'If *across*, the line is drawn across the entire plot area, and', + 'supercedes *toaxis*.', + + 'If *marker*, then a marker dot is drawn on the axis the series is', + 'plotted on' + ].join(' ') + }, tickfont: extendFlat({}, fontAttrs, { description: 'Sets the tick font.' }), diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js index 468e685234b..871b9c6520c 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -173,6 +173,14 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { handleAxisDefaults(axLayoutIn, axLayoutOut, coerce, defaultOptions, layoutOut); + var showSpikes = coerce('showspikes'); + if(showSpikes) { + coerce('spikecolor'); + coerce('spikethickness'); + coerce('spikedash'); + coerce('spikemode'); + } + var positioningOptions = { letter: axLetter, counterAxes: counterAxes[axLetter], diff --git a/src/traces/contour/attributes.js b/src/traces/contour/attributes.js index ee547aa5dbe..069a965af9c 100644 --- a/src/traces/contour/attributes.js +++ b/src/traces/contour/attributes.js @@ -12,6 +12,7 @@ var heatmapAttrs = require('../heatmap/attributes'); var scatterAttrs = require('../scatter/attributes'); var colorscaleAttrs = require('../../components/colorscale/attributes'); var colorbarAttrs = require('../../components/colorbar/attributes'); +var dash = require('../../components/drawing/attributes').dash; var extendFlat = require('../../lib/extend').extendFlat; var scatterLineAttrs = scatterAttrs.line; @@ -118,7 +119,7 @@ module.exports = extendFlat({}, { ].join(' ') }), width: scatterLineAttrs.width, - dash: scatterLineAttrs.dash, + dash: dash, smoothing: extendFlat({}, scatterLineAttrs.smoothing, { description: [ 'Sets the amount of smoothing for the contour lines,', diff --git a/src/traces/ohlc/attributes.js b/src/traces/ohlc/attributes.js index 02d50c76486..938a1d1c81a 100644 --- a/src/traces/ohlc/attributes.js +++ b/src/traces/ohlc/attributes.js @@ -11,6 +11,7 @@ var Lib = require('../../lib'); var scatterAttrs = require('../scatter/attributes'); +var dash = require('../../components/drawing/attributes').dash; var INCREASING_COLOR = '#3D9970'; var DECREASING_COLOR = '#FF4136'; @@ -38,9 +39,9 @@ var directionAttrs = { }, line: { - color: Lib.extendFlat({}, lineAttrs.color), - width: Lib.extendFlat({}, lineAttrs.width), - dash: Lib.extendFlat({}, lineAttrs.dash), + color: lineAttrs.color, + width: lineAttrs.width, + dash: dash, } }; @@ -87,9 +88,9 @@ module.exports = { '`decreasing.line.width`.' ].join(' ') }), - dash: Lib.extendFlat({}, lineAttrs.dash, { + dash: Lib.extendFlat({}, dash, { description: [ - lineAttrs.dash, + dash.description, 'Note that this style setting can also be set per', 'direction via `increasing.line.dash` and', '`decreasing.line.dash`.' diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index 38b3015a4bb..d1bc72ba14c 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -11,6 +11,7 @@ var colorAttributes = require('../../components/colorscale/color_attributes'); var errorBarAttrs = require('../../components/errorbars/attributes'); var colorbarAttrs = require('../../components/colorbar/attributes'); +var dash = require('../../components/drawing/attributes').dash; var Drawing = require('../../components/drawing'); var constants = require('./constants'); @@ -163,20 +164,7 @@ module.exports = { '*0* corresponds to no smoothing (equivalent to a *linear* shape).' ].join(' ') }, - dash: { - valType: 'string', - // string type usually doesn't take values... this one should really be - // a special type or at least a special coercion function, from the GUI - // you only get these values but elsewhere the user can supply a list of - // dash lengths in px, and it will be honored - values: ['solid', 'dot', 'dash', 'longdash', 'dashdot', 'longdashdot'], - dflt: 'solid', - role: 'style', - description: [ - 'Sets the style of the lines. Set to a dash string type', - 'or a dash length in px.' - ].join(' ') - }, + dash: dash, simplify: { valType: 'boolean', dflt: true, diff --git a/src/traces/scatter/line_defaults.js b/src/traces/scatter/line_defaults.js index f0fc1660492..176cbaccc2a 100644 --- a/src/traces/scatter/line_defaults.js +++ b/src/traces/scatter/line_defaults.js @@ -13,7 +13,7 @@ var hasColorscale = require('../../components/colorscale/has_colorscale'); var colorscaleDefaults = require('../../components/colorscale/defaults'); -module.exports = function lineDefaults(traceIn, traceOut, defaultColor, layout, coerce) { +module.exports = function lineDefaults(traceIn, traceOut, defaultColor, layout, coerce, opts) { var markerColor = (traceIn.marker || {}).color; coerce('line.color', defaultColor); @@ -27,5 +27,5 @@ module.exports = function lineDefaults(traceIn, traceOut, defaultColor, layout, } coerce('line.width'); - coerce('line.dash'); + if(!(opts || {}).noDash) coerce('line.dash'); }; diff --git a/src/traces/scatter/marker_defaults.js b/src/traces/scatter/marker_defaults.js index 3211d62426e..65560aecb75 100644 --- a/src/traces/scatter/marker_defaults.js +++ b/src/traces/scatter/marker_defaults.js @@ -16,7 +16,7 @@ var colorscaleDefaults = require('../../components/colorscale/defaults'); var subTypes = require('./subtypes'); -module.exports = function markerDefaults(traceIn, traceOut, defaultColor, layout, coerce) { +module.exports = function markerDefaults(traceIn, traceOut, defaultColor, layout, coerce, opts) { var isBubble = subTypes.isBubble(traceIn), lineColor = (traceIn.line || {}).color, defaultMLC; @@ -33,22 +33,24 @@ module.exports = function markerDefaults(traceIn, traceOut, defaultColor, layout colorscaleDefaults(traceIn, traceOut, layout, coerce, {prefix: 'marker.', cLetter: 'c'}); } - // if there's a line with a different color than the marker, use - // that line color as the default marker line color - // (except when it's an array) - // mostly this is for transparent markers to behave nicely - if(lineColor && !Array.isArray(lineColor) && (traceOut.marker.color !== lineColor)) { - defaultMLC = lineColor; + if(!(opts || {}).noLine) { + // if there's a line with a different color than the marker, use + // that line color as the default marker line color + // (except when it's an array) + // mostly this is for transparent markers to behave nicely + if(lineColor && !Array.isArray(lineColor) && (traceOut.marker.color !== lineColor)) { + defaultMLC = lineColor; + } + else if(isBubble) defaultMLC = Color.background; + else defaultMLC = Color.defaultLine; + + coerce('marker.line.color', defaultMLC); + if(hasColorscale(traceIn, 'marker.line')) { + colorscaleDefaults(traceIn, traceOut, layout, coerce, {prefix: 'marker.line.', cLetter: 'c'}); + } + + coerce('marker.line.width', isBubble ? 1 : 0); } - else if(isBubble) defaultMLC = Color.background; - else defaultMLC = Color.defaultLine; - - coerce('marker.line.color', defaultMLC); - if(hasColorscale(traceIn, 'marker.line')) { - colorscaleDefaults(traceIn, traceOut, layout, coerce, {prefix: 'marker.line.', cLetter: 'c'}); - } - - coerce('marker.line.width', isBubble ? 1 : 0); if(isBubble) { coerce('marker.sizeref'); diff --git a/src/traces/scatter3d/attributes.js b/src/traces/scatter3d/attributes.js index ac0b957be63..7a74fd5b256 100644 --- a/src/traces/scatter3d/attributes.js +++ b/src/traces/scatter3d/attributes.js @@ -11,6 +11,7 @@ var scatterAttrs = require('../scatter/attributes'); var colorAttributes = require('../../components/colorscale/color_attributes'); var errorBarAttrs = require('../../components/errorbars/attributes'); +var DASHES = require('../../constants/gl3d_dashes'); var MARKER_SYMBOLS = require('../../constants/gl_markers'); var extendFlat = require('../../lib/extend').extendFlat; @@ -114,7 +115,13 @@ module.exports = { connectgaps: scatterAttrs.connectgaps, line: extendFlat({}, { width: scatterLineAttrs.width, - dash: scatterLineAttrs.dash, + dash: { + valType: 'enumerated', + values: Object.keys(DASHES), + dflt: 'solid', + role: 'style', + description: 'Sets the dash style of the lines.' + }, showscale: { valType: 'boolean', role: 'info', diff --git a/src/traces/scattergeo/attributes.js b/src/traces/scattergeo/attributes.js index 1b5ddeffc19..a341110e24f 100644 --- a/src/traces/scattergeo/attributes.js +++ b/src/traces/scattergeo/attributes.js @@ -11,6 +11,7 @@ var scatterAttrs = require('../scatter/attributes'); var plotAttrs = require('../../plots/attributes'); var colorAttributes = require('../../components/colorscale/color_attributes'); +var dash = require('../../components/drawing/attributes').dash; var extendFlat = require('../../lib/extend').extendFlat; @@ -79,7 +80,7 @@ module.exports = { line: { color: scatterLineAttrs.color, width: scatterLineAttrs.width, - dash: scatterLineAttrs.dash + dash: dash }, connectgaps: scatterAttrs.connectgaps, diff --git a/src/traces/scattermapbox/attributes.js b/src/traces/scattermapbox/attributes.js index 1518093db19..c061f1141aa 100644 --- a/src/traces/scattermapbox/attributes.js +++ b/src/traces/scattermapbox/attributes.js @@ -61,10 +61,10 @@ module.exports = { line: { color: lineAttrs.color, - width: lineAttrs.width, + width: lineAttrs.width // TODO - dash: lineAttrs.dash + // dash: dash }, connectgaps: scatterAttrs.connectgaps, diff --git a/src/traces/scattermapbox/defaults.js b/src/traces/scattermapbox/defaults.js index ad884eb7f32..f7a51c65e95 100644 --- a/src/traces/scattermapbox/defaults.js +++ b/src/traces/scattermapbox/defaults.js @@ -18,7 +18,6 @@ var handleTextDefaults = require('../scatter/text_defaults'); var handleFillColorDefaults = require('../scatter/fillcolor_defaults'); var attributes = require('./attributes'); -var scatterAttrs = require('../scatter/attributes'); module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout) { @@ -26,15 +25,6 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); } - function coerceMarker(attr, dflt) { - var attrs = (attr.indexOf('.line') === -1) ? attributes : scatterAttrs; - - // use 'scatter' attributes for 'marker.line.' attr, - // so that we can reuse the scatter marker defaults - - return Lib.coerce(traceIn, traceOut, attrs, attr, dflt); - } - var len = handleLonLatDefaults(traceIn, traceOut, coerce); if(!len) { traceOut.visible = false; @@ -46,16 +36,18 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('mode'); if(subTypes.hasLines(traceOut)) { - handleLineDefaults(traceIn, traceOut, defaultColor, layout, coerce); + handleLineDefaults(traceIn, traceOut, defaultColor, layout, coerce, {noDash: true}); coerce('connectgaps'); } if(subTypes.hasMarkers(traceOut)) { - handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerceMarker); + handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, {noLine: true}); // array marker.size and marker.color are only supported with circles var marker = traceOut.marker; + // we need mock marker.line object to make legends happy + marker.line = {width: 0}; if(marker.symbol !== 'circle') { if(Array.isArray(marker.size)) marker.size = marker.size[0]; diff --git a/src/traces/scatterternary/attributes.js b/src/traces/scatterternary/attributes.js index b0c0d4a2acf..4e8c36c48a8 100644 --- a/src/traces/scatterternary/attributes.js +++ b/src/traces/scatterternary/attributes.js @@ -12,6 +12,7 @@ var scatterAttrs = require('../scatter/attributes'); var plotAttrs = require('../../plots/attributes'); var colorAttributes = require('../../components/colorscale/color_attributes'); var colorbarAttrs = require('../../components/colorbar/attributes'); +var dash = require('../../components/drawing/attributes').dash; var extendFlat = require('../../lib/extend').extendFlat; @@ -88,7 +89,7 @@ module.exports = { line: { color: scatterLineAttrs.color, width: scatterLineAttrs.width, - dash: scatterLineAttrs.dash, + dash: dash, shape: extendFlat({}, scatterLineAttrs.shape, {values: ['linear', 'spline']}), smoothing: scatterLineAttrs.smoothing diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index b8830036e8e..eeb0a665743 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -9,6 +9,7 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var mouseEvent = require('../assets/mouse_event'); var click = require('../assets/click'); +var delay = require('../assets/delay'); var doubleClick = require('../assets/double_click'); var fail = require('../assets/fail_test'); @@ -532,6 +533,44 @@ describe('hover info', function() { expect(hovers.size()).toEqual(0); }); }); + + describe('hover events', function() { + var data = [{x: [1, 2, 3], y: [1, 3, 2], type: 'bar'}]; + var layout = {width: 600, height: 400}; + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + Plotly.plot(gd, data, layout).then(done); + }); + + it('should emit events only if the event looks user-driven', function(done) { + var hoverHandler = jasmine.createSpy(); + gd.on('plotly_hover', hoverHandler); + + var gdBB = gd.getBoundingClientRect(); + var event = {clientX: gdBB.left + 300, clientY: gdBB.top + 200}; + + Promise.resolve().then(function() { + Fx.hover(gd, event, 'xy'); + }) + .then(delay(constants.HOVERMINTIME * 1.1)) + .then(function() { + Fx.unhover(gd); + }) + .then(function() { + expect(hoverHandler).not.toHaveBeenCalled(); + var dragger = gd.querySelector('.nsewdrag'); + + Fx.hover(gd, Lib.extendFlat({target: dragger}, event), 'xy'); + }) + .then(function() { + expect(hoverHandler).toHaveBeenCalledTimes(1); + }) + .catch(fail) + .then(done); + }); + }); }); describe('hover info on stacked subplots', function() { diff --git a/test/jasmine/tests/hover_spikeline_test.js b/test/jasmine/tests/hover_spikeline_test.js new file mode 100644 index 00000000000..f8651bbc7ed --- /dev/null +++ b/test/jasmine/tests/hover_spikeline_test.js @@ -0,0 +1,43 @@ +var d3 = require('d3'); + +var Plotly = require('@lib/index'); +var Fx = require('@src/plots/cartesian/graph_interact'); +var Lib = require('@src/lib'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); + +describe('spikeline', function() { + 'use strict'; + + var mock = require('@mocks/19.json'); + + afterEach(destroyGraphDiv); + + describe('hover', function() { + var mockCopy = Lib.extendDeep({}, mock); + + mockCopy.layout.xaxis.showspikes = true; + mockCopy.layout.xaxis.spikemode = 'toaxis'; + mockCopy.layout.yaxis.showspikes = true; + mockCopy.layout.yaxis.spikemode = 'toaxis+marker'; + mockCopy.layout.xaxis2.showspikes = true; + mockCopy.layout.xaxis2.spikemode = 'toaxis'; + mockCopy.layout.hovermode = 'closest'; + beforeEach(function(done) { + Plotly.plot(createGraphDiv(), mockCopy.data, mockCopy.layout).then(done); + }); + + it('draws lines and markers on enabled axes', function() { + Fx.hover('graph', {xval: 2, yval: 3}, 'xy'); + expect(d3.selectAll('line.spikeline').size()).toEqual(4); + expect(d3.selectAll('circle.spikeline').size()).toEqual(1); + }); + + it('doesn\'t draw lines and markers on disabled axes', function() { + Fx.hover('graph', {xval: 30, yval: 40}, 'x2y2'); + expect(d3.selectAll('line.spikeline').size()).toEqual(2); + expect(d3.selectAll('circle.spikeline').size()).toEqual(0); + }); + }); +}); diff --git a/test/jasmine/tests/modebar_test.js b/test/jasmine/tests/modebar_test.js index b3be9a51b43..5133e5fc975 100644 --- a/test/jasmine/tests/modebar_test.js +++ b/test/jasmine/tests/modebar_test.js @@ -185,7 +185,7 @@ describe('ModeBar', function() { ['toImage', 'sendDataToCloud'], ['zoom2d', 'pan2d'], ['zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetScale2d'], - ['hoverClosestCartesian', 'hoverCompareCartesian'] + ['toggleSpikelines', 'hoverClosestCartesian', 'hoverCompareCartesian'] ]); var gd = getMockGraphInfo(); @@ -203,7 +203,7 @@ describe('ModeBar', function() { ['toImage', 'sendDataToCloud'], ['zoom2d', 'pan2d', 'select2d', 'lasso2d'], ['zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetScale2d'], - ['hoverClosestCartesian', 'hoverCompareCartesian'] + ['toggleSpikelines', 'hoverClosestCartesian', 'hoverCompareCartesian'] ]); var gd = getMockGraphInfo(); @@ -225,7 +225,7 @@ describe('ModeBar', function() { it('creates mode bar (cartesian fixed-axes version)', function() { var buttons = getButtons([ ['toImage', 'sendDataToCloud'], - ['hoverClosestCartesian', 'hoverCompareCartesian'] + ['toggleSpikelines', 'hoverClosestCartesian', 'hoverCompareCartesian'] ]); var gd = getMockGraphInfo(); @@ -412,7 +412,7 @@ describe('ModeBar', function() { var buttons = getButtons([ ['toImage', 'sendDataToCloud'], ['zoom2d', 'pan2d'], - ['hoverClosestCartesian', 'hoverCompareCartesian'] + ['toggleSpikelines', 'hoverClosestCartesian', 'hoverCompareCartesian'] ]); var gd = getMockGraphInfo(); @@ -544,7 +544,7 @@ describe('ModeBar', function() { var modeBar = gd._fullLayout._modeBar; expect(countGroups(modeBar)).toEqual(6); - expect(countButtons(modeBar)).toEqual(10); + expect(countButtons(modeBar)).toEqual(11); }); it('sets up buttons with modeBarButtonsToAdd and modeBarButtonToRemove (2)', function() { @@ -564,7 +564,7 @@ describe('ModeBar', function() { var modeBar = gd._fullLayout._modeBar; expect(countGroups(modeBar)).toEqual(7); - expect(countButtons(modeBar)).toEqual(12); + expect(countButtons(modeBar)).toEqual(13); }); it('sets up buttons with fully custom modeBarButtons', function() { @@ -612,7 +612,7 @@ describe('ModeBar', function() { }); describe('modebar on clicks', function() { - var gd, modeBar; + var gd, modeBar, buttonClosest, buttonCompare, buttonToggle, hovermodeButtons; beforeAll(function() { jasmine.addMatchers(customMatchers); @@ -685,6 +685,10 @@ describe('ModeBar', function() { gd = createGraphDiv(); Plotly.plot(gd, mockData, mockLayout).then(function() { modeBar = gd._fullLayout._modeBar; + buttonToggle = selectButton(modeBar, 'toggleSpikelines'); + buttonCompare = selectButton(modeBar, 'hoverCompareCartesian'); + buttonClosest = selectButton(modeBar, 'hoverClosestCartesian'); + hovermodeButtons = [buttonCompare, buttonClosest]; done(); }); }); @@ -758,21 +762,53 @@ describe('ModeBar', function() { }); describe('buttons hoverCompareCartesian and hoverClosestCartesian ', function() { - it('should update layout hovermode', function() { - var buttonCompare = selectButton(modeBar, 'hoverCompareCartesian'), - buttonClosest = selectButton(modeBar, 'hoverClosestCartesian'), - buttons = [buttonCompare, buttonClosest]; + it('should update layout hovermode', function() { expect(gd._fullLayout.hovermode).toBe('x'); - assertActive(buttons, buttonCompare); + assertActive(hovermodeButtons, buttonCompare); buttonClosest.click(); expect(gd._fullLayout.hovermode).toBe('closest'); - assertActive(buttons, buttonClosest); + assertActive(hovermodeButtons, buttonClosest); buttonCompare.click(); expect(gd._fullLayout.hovermode).toBe('x'); - assertActive(buttons, buttonCompare); + assertActive(hovermodeButtons, buttonCompare); + }); + }); + + describe('button toggleSpikelines', function() { + it('should update layout hovermode', function() { + expect(gd._fullLayout.hovermode).toBe('x'); + assertActive(hovermodeButtons, buttonCompare); + + buttonToggle.click(); + expect(gd._fullLayout.hovermode).toBe('closest'); + assertActive(hovermodeButtons, buttonClosest); + }); + it('should makes spikelines visible', function() { + buttonToggle.click(); + expect(gd._fullLayout._cartesianSpikesEnabled).toBe('on'); + + buttonToggle.click(); + expect(gd._fullLayout._cartesianSpikesEnabled).toBe('off'); + }); + it('should become disabled when hovermode is switched off closest', function() { + buttonToggle.click(); + expect(gd._fullLayout._cartesianSpikesEnabled).toBe('on'); + + buttonCompare.click(); + expect(gd._fullLayout._cartesianSpikesEnabled).toBe('off'); + }); + it('should be re-enabled when hovermode is set to closest if it was previously on', function() { + buttonToggle.click(); + expect(gd._fullLayout._cartesianSpikesEnabled).toBe('on'); + + buttonCompare.click(); + expect(gd._fullLayout._cartesianSpikesEnabled).toBe('off'); + + buttonClosest.click(); + expect(gd._fullLayout._cartesianSpikesEnabled).toBe('on'); }); }); });