diff --git a/src/components/dragelement/index.js b/src/components/dragelement/index.js index 8b531a1b5a8..156382b6c5d 100644 --- a/src/components/dragelement/index.js +++ b/src/components/dragelement/index.js @@ -13,7 +13,7 @@ var Plotly = require('../../plotly'); var Lib = require('../../lib'); var constants = require('../../plots/cartesian/constants'); - +var interactConstants = require('../../constants/interactions'); var dragElement = module.exports = {}; @@ -37,12 +37,13 @@ dragElement.unhoverRaw = unhover.raw; * dx and dy are the net pixel offset of the drag, * dragged is true/false, has the mouse moved enough to * constitute a drag - * doneFn (optional) function(dragged, numClicks) + * doneFn (optional) function(dragged, numClicks, e) * executed on mouseup, or mouseout of window since * we don't get events after that * dragged is as in moveFn * numClicks is how many clicks we've registered within * a doubleclick time + * e is the original event * setCursor (optional) function(event) * executed on mousemove before mousedown * the purpose of this callback is to update the mouse cursor before @@ -51,7 +52,7 @@ dragElement.unhoverRaw = unhover.raw; dragElement.init = function init(options) { var gd = Lib.getPlotDiv(options.element) || {}, numClicks = 1, - DBLCLICKDELAY = constants.DBLCLICKDELAY, + DBLCLICKDELAY = interactConstants.DBLCLICKDELAY, startX, startY, newMouseDownTime, @@ -136,7 +137,7 @@ dragElement.init = function init(options) { numClicks = Math.max(numClicks - 1, 1); } - if(options.doneFn) options.doneFn(gd._dragged, numClicks); + if(options.doneFn) options.doneFn(gd._dragged, numClicks, e); if(!gd._dragged) { var e2 = document.createEvent('MouseEvents'); diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js index d6c836c14e1..beb5f58a1c8 100644 --- a/src/components/legend/draw.js +++ b/src/components/legend/draw.js @@ -21,11 +21,14 @@ var Color = require('../color'); var svgTextUtils = require('../../lib/svg_text_utils'); var constants = require('./constants'); +var interactConstants = require('../../constants/interactions'); var getLegendData = require('./get_legend_data'); var style = require('./style'); var helpers = require('./helpers'); var anchorUtils = require('./anchor_utils'); +var SHOWISOLATETIP = true; +var DBLCLICKDELAY = interactConstants.DBLCLICKDELAY; module.exports = function draw(gd) { var fullLayout = gd._fullLayout; @@ -33,6 +36,8 @@ module.exports = function draw(gd) { if(!fullLayout._infolayer || !gd.calcdata) return; + if(!gd._legendMouseDownTime) gd._legendMouseDownTime = 0; + var opts = fullLayout.legend, legendData = fullLayout.showlegend && getLegendData(gd.calcdata, opts), hiddenSlices = fullLayout.hiddenlabels || []; @@ -325,9 +330,26 @@ module.exports = function draw(gd) { xf = dragElement.align(newX, 0, gs.l, gs.l + gs.w, opts.xanchor); yf = dragElement.align(newY, 0, gs.t + gs.h, gs.t, opts.yanchor); }, - doneFn: function(dragged) { + doneFn: function(dragged, numClicks, e) { if(dragged && xf !== undefined && yf !== undefined) { Plotly.relayout(gd, {'legend.x': xf, 'legend.y': yf}); + } else { + var clickedTrace = + fullLayout._infolayer.selectAll('g.traces').filter(function() { + var bbox = this.getBoundingClientRect(); + return (e.clientX >= bbox.left && e.clientX <= bbox.right && + e.clientY >= bbox.top && e.clientY <= bbox.bottom); + }); + if(clickedTrace.size() > 0) { + if(numClicks === 1) { + legend._clickTimeout = setTimeout(function() { handleClick(clickedTrace, gd, numClicks); }, DBLCLICKDELAY); + } else if(numClicks === 2) { + if(legend._clickTimeout) { + clearTimeout(legend._clickTimeout); + } + handleClick(clickedTrace, gd, numClicks); + } + } } } }); @@ -395,9 +417,8 @@ function drawTexts(g, gd) { } function setupTraceToggle(g, gd) { - var hiddenSlices = gd._fullLayout.hiddenlabels ? - gd._fullLayout.hiddenlabels.slice() : - []; + var newMouseDownTime, + numClicks = 1; var traceToggle = g.selectAll('rect') .data([0]); @@ -408,41 +429,120 @@ function setupTraceToggle(g, gd) { .attr('pointer-events', 'all') .call(Color.fill, 'rgba(0,0,0,0)'); - traceToggle.on('click', function() { - if(gd._dragged) return; - var legendItem = g.data()[0][0], - fullData = gd._fullData, - trace = legendItem.trace, - legendgroup = trace.legendgroup, - traceIndicesInGroup = [], - tracei, - newVisible; + traceToggle.on('mousedown', function() { + newMouseDownTime = (new Date()).getTime(); + if(newMouseDownTime - gd._legendMouseDownTime < DBLCLICKDELAY) { + // in a click train + numClicks += 1; + } + else { + // new click train + numClicks = 1; + gd._legendMouseDownTime = newMouseDownTime; + } + }); + traceToggle.on('mouseup', function() { + if(gd._dragged || gd._editing) return; + var legend = gd._fullLayout.legend; - if(Registry.traceIs(trace, 'pie')) { - var thisLabel = legendItem.label, - thisLabelIndex = hiddenSlices.indexOf(thisLabel); + if((new Date()).getTime() - gd._legendMouseDownTime > DBLCLICKDELAY) { + numClicks = Math.max(numClicks - 1, 1); + } + + if(numClicks === 1) { + legend._clickTimeout = setTimeout(function() { handleClick(g, gd, numClicks); }, DBLCLICKDELAY); + } else if(numClicks === 2) { + if(legend._clickTimeout) { + clearTimeout(legend._clickTimeout); + } + gd._legendMouseDownTime = 0; + handleClick(g, gd, numClicks); + } + }); +} + +function handleClick(g, gd, numClicks) { + if(gd._dragged || gd._editing) return; + var hiddenSlices = gd._fullLayout.hiddenlabels ? + gd._fullLayout.hiddenlabels.slice() : + []; + + var legendItem = g.data()[0][0], + fullData = gd._fullData, + trace = legendItem.trace, + legendgroup = trace.legendgroup, + traceIndicesInGroup = [], + tracei, + newVisible; + + if(numClicks === 1 && SHOWISOLATETIP && gd.data && gd._context.showTips) { + Lib.notifier('Double click on legend to isolate individual trace', 'long'); + SHOWISOLATETIP = false; + } else { + SHOWISOLATETIP = false; + } + if(Registry.traceIs(trace, 'pie')) { + var thisLabel = legendItem.label, + thisLabelIndex = hiddenSlices.indexOf(thisLabel); + + if(numClicks === 1) { if(thisLabelIndex === -1) hiddenSlices.push(thisLabel); else hiddenSlices.splice(thisLabelIndex, 1); + } else if(numClicks === 2) { + hiddenSlices = []; + gd.calcdata[0].forEach(function(d) { + if(thisLabel !== d.label) { + hiddenSlices.push(d.label); + } + }); + if(gd._fullLayout.hiddenlabels && gd._fullLayout.hiddenlabels.length === hiddenSlices.length && thisLabelIndex === -1) { + hiddenSlices = []; + } + } + + Plotly.relayout(gd, 'hiddenlabels', hiddenSlices); + } else { + var allTraces = [], + traceVisibility = [], + i; - Plotly.relayout(gd, 'hiddenlabels', hiddenSlices); + for(i = 0; i < fullData.length; i++) { + allTraces.push(i); + traceVisibility.push('legendonly'); + } + + if(legendgroup === '') { + traceIndicesInGroup = [trace.index]; + traceVisibility[trace.index] = true; } else { - if(legendgroup === '') { - traceIndicesInGroup = [trace.index]; - } else { - for(var i = 0; i < fullData.length; i++) { - tracei = fullData[i]; - if(tracei.legendgroup === legendgroup) { - traceIndicesInGroup.push(tracei.index); - } + for(i = 0; i < fullData.length; i++) { + tracei = fullData[i]; + if(tracei.legendgroup === legendgroup) { + traceIndicesInGroup.push(tracei.index); + traceVisibility[allTraces.indexOf(i)] = true; } } + } + if(numClicks === 1) { newVisible = trace.visible === true ? 'legendonly' : true; Plotly.restyle(gd, 'visible', newVisible, traceIndicesInGroup); + } else if(numClicks === 2) { + var sameAsLast = true; + for(i = 0; i < fullData.length; i++) { + if(fullData[i].visible !== traceVisibility[i]) { + sameAsLast = false; + break; + } + } + if(sameAsLast) { + traceVisibility = true; + } + Plotly.restyle(gd, 'visible', traceVisibility, allTraces); } - }); + } } function computeTextDimensions(g, gd) { diff --git a/src/constants/interactions.js b/src/constants/interactions.js index 132e9ca4c37..3e56a09f434 100644 --- a/src/constants/interactions.js +++ b/src/constants/interactions.js @@ -14,5 +14,9 @@ module.exports = { * Timing information for interactive elements */ SHOW_PLACEHOLDER: 100, - HIDE_PLACEHOLDER: 1000 + HIDE_PLACEHOLDER: 1000, + + // ms between first mousedown and 2nd mouseup to constitute dblclick... + // we don't seem to have access to the system setting + DBLCLICKDELAY: 300 }; diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index a55334d5157..f6d4419dc0c 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -467,7 +467,8 @@ exports.makeEditable = function(context, _delegate, options) { } function appendEditable() { - var plotDiv = d3.select(Lib.getPlotDiv(that.node())), + var gd = Lib.getPlotDiv(that.node()), + plotDiv = d3.select(gd), container = plotDiv.select('.svg-container'), div = container.append('div'); div.classed('plugin-editable editable', true) @@ -487,6 +488,7 @@ exports.makeEditable = function(context, _delegate, options) { .text(options.text || that.attr('data-unformatted')) .call(alignHTMLWith(that, container, options)) .on('blur', function() { + gd._editing = false; that.text(this.textContent) .style({opacity: 1}); var svgClass = d3.select(this).attr('class'), @@ -503,6 +505,7 @@ exports.makeEditable = function(context, _delegate, options) { }) .on('focus', function() { var context = this; + gd._editing = true; d3.select(document).on('mouseup', function() { if(d3.event.target === context) return false; if(document.activeElement === div.node()) div.node().blur(); @@ -510,6 +513,7 @@ exports.makeEditable = function(context, _delegate, options) { }) .on('keyup', function() { if(d3.event.which === 27) { + gd._editing = false; that.style({opacity: 1}); d3.select(this) .style({opacity: 0}) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 53fe0fe56cf..2e3f0c373ca 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2794,6 +2794,7 @@ Plotly.purge = function purge(gd) { delete gd._context; delete gd._replotPending; delete gd._mouseDownTime; + delete gd._legendMouseDownTime; delete gd._hmpixcount; delete gd._hmlumcount; diff --git a/src/plots/cartesian/constants.js b/src/plots/cartesian/constants.js index 8d7ed20df45..2934d4ecd0f 100644 --- a/src/plots/cartesian/constants.js +++ b/src/plots/cartesian/constants.js @@ -29,10 +29,6 @@ module.exports = { AX_ID_PATTERN: /^[xyz][0-9]*$/, AX_NAME_PATTERN: /^[xyz]axis[0-9]*$/, - // ms between first mousedown and 2nd mouseup to constitute dblclick... - // we don't seem to have access to the system setting - DBLCLICKDELAY: 300, - // pixels to move mouse before you stop clamping to starting point MINDRAG: 8, diff --git a/test/jasmine/assets/double_click.js b/test/jasmine/assets/double_click.js index e92375588a8..2e66c90f952 100644 --- a/test/jasmine/assets/double_click.js +++ b/test/jasmine/assets/double_click.js @@ -1,5 +1,5 @@ var click = require('./click'); -var DBLCLICKDELAY = require('@src/plots/cartesian/constants').DBLCLICKDELAY; +var DBLCLICKDELAY = require('@src/constants/interactions').DBLCLICKDELAY; module.exports = function doubleClick(x, y) { return new Promise(function(resolve) { diff --git a/test/jasmine/tests/click_test.js b/test/jasmine/tests/click_test.js index b38c7e1d476..9e13b9ffd12 100644 --- a/test/jasmine/tests/click_test.js +++ b/test/jasmine/tests/click_test.js @@ -1,7 +1,7 @@ var Plotly = require('@lib/index'); var Lib = require('@src/lib'); var Drawing = require('@src/components/drawing'); -var DBLCLICKDELAY = require('@src/plots/cartesian/constants').DBLCLICKDELAY; +var DBLCLICKDELAY = require('@src/constants/interactions').DBLCLICKDELAY; var d3 = require('d3'); var createGraphDiv = require('../assets/create_graph_div'); diff --git a/test/jasmine/tests/legend_scroll_test.js b/test/jasmine/tests/legend_scroll_test.js index b93610ac5f7..64087fd14a1 100644 --- a/test/jasmine/tests/legend_scroll_test.js +++ b/test/jasmine/tests/legend_scroll_test.js @@ -1,6 +1,7 @@ var Plotly = require('@lib/index'); var Lib = require('@src/lib'); var constants = require('@src/components/legend/constants'); +var DBLCLICKDELAY = require('@src/constants/interactions').DBLCLICKDELAY; var d3 = require('d3'); var createGraph = require('../assets/create_graph_div'); @@ -8,7 +9,6 @@ var destroyGraph = require('../assets/destroy_graph_div'); var getBBox = require('../assets/get_bbox'); var mock = require('../../image/mocks/legend_scroll.json'); - describe('The legend', function() { 'use strict'; @@ -96,7 +96,7 @@ describe('The legend', function() { 'translate(0, ' + finalDataScroll + ')'); }); - it('should keep the scrollbar position after a toggle event', function() { + it('should keep the scrollbar position after a toggle event', function(done) { var legend = getLegend(), scrollBox = getScrollBox(), toggle = getToggle(), @@ -105,14 +105,18 @@ describe('The legend', function() { legend.dispatchEvent(scrollTo(wheelDeltaY)); var dataScroll = scrollBox.getAttribute('data-scroll'); - toggle.dispatchEvent(new MouseEvent('click')); - expect(+toggle.parentNode.style.opacity).toBeLessThan(1); - expect(scrollBox.getAttribute('data-scroll')).toBe(dataScroll); - expect(scrollBox.getAttribute('transform')).toBe( - 'translate(0, ' + dataScroll + ')'); + toggle.dispatchEvent(new MouseEvent('mousedown')); + toggle.dispatchEvent(new MouseEvent('mouseup')); + setTimeout(function() { + expect(+toggle.parentNode.style.opacity).toBeLessThan(1); + expect(scrollBox.getAttribute('data-scroll')).toBe(dataScroll); + expect(scrollBox.getAttribute('transform')).toBe( + 'translate(0, ' + dataScroll + ')'); + done(); + }, DBLCLICKDELAY * 2); }); - it('should be restored and functional after relayout', function() { + it('should be restored and functional after relayout', function(done) { var wheelDeltaY = 100, legend = getLegend(), scrollBox, @@ -139,13 +143,17 @@ describe('The legend', function() { expect(scrollBar.getAttribute('y')).toBe(scrollBarY); var dataScroll = scrollBox.getAttribute('data-scroll'); - toggle.dispatchEvent(new MouseEvent('click')); - expect(+toggle.parentNode.style.opacity).toBeLessThan(1); - expect(scrollBox.getAttribute('data-scroll')).toBe(dataScroll); - expect(scrollBox.getAttribute('transform')).toBe( - 'translate(0, ' + dataScroll + ')'); - expect(scrollBar.getAttribute('width')).toBeGreaterThan(0); - expect(scrollBar.getAttribute('height')).toBeGreaterThan(0); + toggle.dispatchEvent(new MouseEvent('mousedown')); + toggle.dispatchEvent(new MouseEvent('mouseup')); + setTimeout(function() { + expect(+toggle.parentNode.style.opacity).toBeLessThan(1); + expect(scrollBox.getAttribute('data-scroll')).toBe(dataScroll); + expect(scrollBox.getAttribute('transform')).toBe( + 'translate(0, ' + dataScroll + ')'); + expect(scrollBar.getAttribute('width')).toBeGreaterThan(0); + expect(scrollBar.getAttribute('height')).toBeGreaterThan(0); + done(); + }, DBLCLICKDELAY * 2); }); it('should constrain scrolling to the contents', function() { diff --git a/test/jasmine/tests/legend_test.js b/test/jasmine/tests/legend_test.js index c832da02dcd..7cb4f06974b 100644 --- a/test/jasmine/tests/legend_test.js +++ b/test/jasmine/tests/legend_test.js @@ -1,6 +1,7 @@ var Plotly = require('@lib/index'); var Plots = require('@src/plots/plots'); var Lib = require('@src/lib'); +var DBLCLICKDELAY = require('@src/constants/interactions').DBLCLICKDELAY; var Legend = require('@src/components/legend'); var getLegendData = require('@src/components/legend/get_legend_data'); @@ -628,3 +629,188 @@ describe('legend restyle update', function() { }); }); }); + +describe('legend interaction', function() { + 'use strict'; + + describe('pie chart', function() { + var mockCopy, gd, legendItems, legendItem, legendLabels, legendLabel; + var testEntry = 2; + + beforeAll(function(done) { + var mock = require('@mocks/pie_simple.json'); + mockCopy = Lib.extendDeep({}, mock); + gd = createGraphDiv(); + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() { + legendItems = d3.selectAll('rect.legendtoggle')[0]; + legendLabels = d3.selectAll('text.legendtext')[0]; + legendItem = legendItems[testEntry]; + legendLabel = legendLabels[testEntry].innerHTML; + done(); + }); + }); + afterAll(function() { + destroyGraphDiv(); + }); + describe('single click', function() { + it('should hide slice', function(done) { + legendItem.dispatchEvent(new MouseEvent('mousedown')); + legendItem.dispatchEvent(new MouseEvent('mouseup')); + setTimeout(function() { + expect(gd._fullLayout.hiddenlabels.length).toBe(1); + expect(gd._fullLayout.hiddenlabels[0]).toBe(legendLabel); + done(); + }, DBLCLICKDELAY + 20); + }); + it('should fade legend item', function() { + expect(+legendItem.parentNode.style.opacity).toBeLessThan(1); + }); + it('should unhide slice', function(done) { + legendItem.dispatchEvent(new MouseEvent('mousedown')); + legendItem.dispatchEvent(new MouseEvent('mouseup')); + setTimeout(function() { + expect(gd._fullLayout.hiddenlabels.length).toBe(0); + done(); + }, DBLCLICKDELAY + 20); + }); + it('should unfade legend item', function() { + expect(+legendItem.parentNode.style.opacity).toBe(1); + }); + }); + + describe('double click', function() { + it('should hide other slices', function(done) { + legendItem.dispatchEvent(new MouseEvent('mousedown')); + legendItem.dispatchEvent(new MouseEvent('mouseup')); + legendItem.dispatchEvent(new MouseEvent('mousedown')); + legendItem.dispatchEvent(new MouseEvent('mouseup')); + setTimeout(function() { + expect(gd._fullLayout.hiddenlabels.length).toBe((legendItems.length - 1)); + expect(gd._fullLayout.hiddenlabels.indexOf(legendLabel)).toBe(-1); + done(); + }, 20); + }); + it('should fade other legend items', function() { + var legendItemi; + for(var i = 0; i < legendItems.length; i++) { + legendItemi = legendItems[i]; + if(i === testEntry) { + expect(+legendItemi.parentNode.style.opacity).toBe(1); + } else { + expect(+legendItemi.parentNode.style.opacity).toBeLessThan(1); + } + } + }); + it('should unhide all slices', function(done) { + legendItem.dispatchEvent(new MouseEvent('mousedown')); + legendItem.dispatchEvent(new MouseEvent('mouseup')); + legendItem.dispatchEvent(new MouseEvent('mousedown')); + legendItem.dispatchEvent(new MouseEvent('mouseup')); + setTimeout(function() { + expect(gd._fullLayout.hiddenlabels.length).toBe(0); + done(); + }, 20); + }); + it('should unfade legend items', function() { + var legendItemi; + for(var i = 0; i < legendItems.length; i++) { + legendItemi = legendItems[i]; + expect(+legendItemi.parentNode.style.opacity).toBe(1); + } + }); + }); + }); + describe('non-pie chart', function() { + var mockCopy, gd, legendItems, legendItem; + var testEntry = 2; + + beforeAll(function(done) { + var mock = require('@mocks/29.json'); + mockCopy = Lib.extendDeep({}, mock); + gd = createGraphDiv(); + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() { + legendItems = d3.selectAll('rect.legendtoggle')[0]; + legendItem = legendItems[testEntry]; + done(); + }); + }); + afterAll(function() { + destroyGraphDiv(); + }); + + describe('single click', function() { + it('should hide series', function(done) { + legendItem.dispatchEvent(new MouseEvent('mousedown')); + legendItem.dispatchEvent(new MouseEvent('mouseup')); + setTimeout(function() { + expect(gd.data[2].visible).toBe('legendonly'); + done(); + }, DBLCLICKDELAY + 20); + }); + it('should fade legend item', function() { + expect(+legendItem.parentNode.style.opacity).toBeLessThan(1); + }); + it('should unhide series', function(done) { + legendItem.dispatchEvent(new MouseEvent('mousedown')); + legendItem.dispatchEvent(new MouseEvent('mouseup')); + setTimeout(function() { + expect(gd.data[2].visible).toBe(true); + done(); + }, DBLCLICKDELAY + 20); + }); + it('should unfade legend item', function() { + expect(+legendItem.parentNode.style.opacity).toBe(1); + }); + }); + describe('double click', function() { + it('should hide series', function(done) { + legendItem.dispatchEvent(new MouseEvent('mousedown')); + legendItem.dispatchEvent(new MouseEvent('mouseup')); + legendItem.dispatchEvent(new MouseEvent('mousedown')); + legendItem.dispatchEvent(new MouseEvent('mouseup')); + setTimeout(function() { + for(var i = 0; i < legendItems.length; i++) { + if(i === testEntry) { + expect(gd.data[i].visible).toBe(true); + } else { + expect(gd.data[i].visible).toBe('legendonly'); + } + } + done(); + }, 20); + }); + it('should fade legend item', function() { + var legendItemi; + for(var i = 0; i < legendItems.length; i++) { + legendItemi = legendItems[i]; + if(i === testEntry) { + expect(+legendItemi.parentNode.style.opacity).toBe(1); + } else { + expect(+legendItemi.parentNode.style.opacity).toBeLessThan(1); + } + } + }); + it('should unhide series', function(done) { + legendItem.dispatchEvent(new MouseEvent('mousedown')); + legendItem.dispatchEvent(new MouseEvent('mouseup')); + legendItem.dispatchEvent(new MouseEvent('mousedown')); + legendItem.dispatchEvent(new MouseEvent('mouseup')); + setTimeout(function() { + for(var i = 0; i < legendItems.length; i++) { + expect(gd.data[i].visible).toBe(true); + } + done(); + }, 20); + }); + it('should unfade legend items', function() { + var legendItemi; + for(var i = 0; i < legendItems.length; i++) { + legendItemi = legendItems[i]; + expect(+legendItemi.parentNode.style.opacity).toBe(1); + } + }); + }); + }); +}); diff --git a/test/jasmine/tests/plots_test.js b/test/jasmine/tests/plots_test.js index b2bd10f5cca..bf95239103d 100644 --- a/test/jasmine/tests/plots_test.js +++ b/test/jasmine/tests/plots_test.js @@ -397,7 +397,7 @@ describe('Test Plots', function() { '_ev', '_internalEv', 'on', 'once', 'removeListener', 'removeAllListeners', '_internalOn', '_internalOnce', '_removeInternalListener', '_removeAllInternalListeners', 'emit', '_context', '_replotPending', - '_hmpixcount', '_hmlumcount', '_mouseDownTime' + '_hmpixcount', '_hmlumcount', '_mouseDownTime', '_legendMouseDownTime', ]; Plots.purge(gd);