diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js index 111d5191747..6e6def4260d 100644 --- a/src/components/legend/draw.js +++ b/src/components/legend/draw.js @@ -6,7 +6,6 @@ * LICENSE file in the root directory of this source tree. */ - 'use strict'; var d3 = require('d3'); @@ -19,6 +18,7 @@ var dragElement = require('../dragelement'); var Drawing = require('../drawing'); var Color = require('../color'); var svgTextUtils = require('../../lib/svg_text_utils'); +var handleClick = require('./handle_click'); var constants = require('./constants'); var interactConstants = require('../../constants/interactions'); @@ -29,7 +29,6 @@ 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) { @@ -413,23 +412,24 @@ function drawTexts(g, gd) { var groupbyIndices = Registry.getTransformIndices(fullInput, 'groupby'); var index = groupbyIndices[groupbyIndices.length - 1]; - var carr = Lib.keyedContainer(fullInput, 'transforms[' + index + '].styles', 'target', 'value.name'); + var kcont = Lib.keyedContainer(fullInput, 'transforms[' + index + '].styles', 'target', 'value.name'); if(origText === '') { - carr.remove(legendItem.trace._group); + kcont.remove(legendItem.trace._group); } else { - carr.set(legendItem.trace._group, text); + kcont.set(legendItem.trace._group, text); } - update = carr.constructUpdate(); + update = kcont.constructUpdate(); } else { update.name = text; } return Plotly.restyle(gd, update, traceIndex); }); + } else { + text.call(textLayout); } - else text.call(textLayout); } function setupTraceToggle(g, gd) { @@ -478,97 +478,6 @@ function setupTraceToggle(g, gd) { }); } -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; - - for(i = 0; i < fullData.length; i++) { - allTraces.push(i); - // Allow the legendonly state through for *all* trace types (including - // carpet for which it's overridden with true/false in supplyDefaults) - traceVisibility.push( - Registry.traceIs(fullData[i], 'notLegendIsolatable') ? true : 'legendonly' - ); - } - - if(legendgroup === '') { - traceIndicesInGroup = [trace.index]; - traceVisibility[trace.index] = true; - } else { - 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; - } - var visibilityUpdates = []; - for(i = 0; i < fullData.length; i++) { - visibilityUpdates.push(allTraces[i]); - } - Plotly.restyle(gd, 'visible', traceVisibility, visibilityUpdates); - } - } -} - function computeTextDimensions(g, gd) { var legendItem = g.data()[0][0]; diff --git a/src/components/legend/handle_click.js b/src/components/legend/handle_click.js new file mode 100644 index 00000000000..46ce749f16f --- /dev/null +++ b/src/components/legend/handle_click.js @@ -0,0 +1,223 @@ +/** +* 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'; + +var Plotly = require('../../plotly'); +var Lib = require('../../lib'); +var Registry = require('../../registry'); + +var SHOWISOLATETIP = true; + +module.exports = 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]; + var fullData = gd._fullData; + var fullTrace = legendItem.trace; + var legendgroup = fullTrace.legendgroup; + + var i, j, kcont, key, keys, val; + var attrUpdate = {}; + var attrIndices = []; + var carrs = []; + var carrIdx = []; + + function insertUpdate(traceIndex, key, value) { + var attrIndex = attrIndices.indexOf(traceIndex); + var valueArray = attrUpdate[key]; + if(!valueArray) { + valueArray = attrUpdate[key] = []; + } + + if(attrIndices.indexOf(traceIndex) === -1) { + attrIndices.push(traceIndex); + attrIndex = attrIndices.length - 1; + } + + valueArray[attrIndex] = value; + + return attrIndex; + } + + function setVisibility(fullTrace, visibility) { + var fullInput = fullTrace._fullInput; + if(Registry.hasTransform(fullInput, 'groupby')) { + var kcont = carrs[fullInput.index]; + if(!kcont) { + var groupbyIndices = Registry.getTransformIndices(fullInput, 'groupby'); + var lastGroupbyIndex = groupbyIndices[groupbyIndices.length - 1]; + kcont = Lib.keyedContainer(fullInput, 'transforms[' + lastGroupbyIndex + '].styles', 'target', 'value.visible'); + carrs[fullInput.index] = kcont; + } + + var curState = kcont.get(fullTrace._group); + + // If not specified, assume visible. This happens if there are other style + // properties set for a group but not the visibility. There are many similar + // ways to do this (e.g. why not just `curState = fullTrace.visible`??? The + // answer is: because it breaks other things like groupby trace names in + // subtle ways.) + if(curState === undefined) { + curState = true; + } + + if(curState !== false) { + // true -> legendonly. All others toggle to true: + kcont.set(fullTrace._group, visibility); + } + carrIdx[fullInput.index] = insertUpdate(fullInput.index, 'visible', fullInput.visible === false ? false : true); + } else { + // false -> false (not possible since will not be visible in legend) + // true -> legendonly + // legendonly -> true + var nextVisibility = fullInput.visible === false ? false : visibility; + + insertUpdate(fullInput.index, 'visible', nextVisibility); + } + } + + 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(fullTrace, '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 hasLegendgroup = legendgroup && legendgroup.length; + var traceIndicesInGroup = []; + var tracei; + if(hasLegendgroup) { + for(i = 0; i < fullData.length; i++) { + tracei = fullData[i]; + if(!tracei.visible) continue; + if(tracei.legendgroup === legendgroup) { + traceIndicesInGroup.push(i); + } + } + } + + if(numClicks === 1) { + var nextVisibility; + + switch(fullTrace.visible) { + case true: + nextVisibility = 'legendonly'; + break; + case false: + nextVisibility = false; + break; + case 'legendonly': + nextVisibility = true; + break; + } + + if(hasLegendgroup) { + for(i = 0; i < fullData.length; i++) { + if(fullData[i].visible !== false && fullData[i].legendgroup === legendgroup) { + setVisibility(fullData[i], nextVisibility); + } + } + } else { + setVisibility(fullTrace, nextVisibility); + } + } else if(numClicks === 2) { + // Compute the clicked index. expandedIndex does what we want for expanded traces + // but also culls hidden traces. That means we have some work to do. + var isClicked, isInGroup, otherState; + var isIsolated = true; + for(i = 0; i < fullData.length; i++) { + isClicked = fullData[i] === fullTrace; + if(isClicked) continue; + + isInGroup = (hasLegendgroup && fullData[i].legendgroup === legendgroup); + + if(!isInGroup && fullData[i].visible === true && !Registry.traceIs(fullData[i], 'notLegendIsolatable')) { + isIsolated = false; + break; + } + } + + for(i = 0; i < fullData.length; i++) { + // False is sticky; we don't change it. + if(fullData[i].visible === false) continue; + + if(Registry.traceIs(fullData[i], 'notLegendIsolatable')) { + continue; + } + + switch(fullTrace.visible) { + case 'legendonly': + setVisibility(fullData[i], true); + break; + case true: + otherState = isIsolated ? true : 'legendonly'; + isClicked = fullData[i] === fullTrace; + isInGroup = isClicked || (hasLegendgroup && fullData[i].legendgroup === legendgroup); + setVisibility(fullData[i], isInGroup ? true : otherState); + break; + } + } + } + + for(i = 0; i < carrs.length; i++) { + kcont = carrs[i]; + if(!kcont) continue; + var update = kcont.constructUpdate(); + + var updateKeys = Object.keys(update); + for(j = 0; j < updateKeys.length; j++) { + key = updateKeys[j]; + val = attrUpdate[key] = attrUpdate[key] || []; + val[carrIdx[i]] = update[key]; + } + } + + // The length of the value arrays should be equal and any unspecified + // values should be explicitly undefined for them to get properly culled + // as updates and not accidentally reset to the default value. This fills + // out sparse arrays with the required number of undefined values: + keys = Object.keys(attrUpdate); + for(i = 0; i < keys.length; i++) { + key = keys[i]; + for(j = 0; j < attrIndices.length; j++) { + // Use hasOwnPropety to protect against falsey values: + if(!attrUpdate[key].hasOwnProperty(j)) { + attrUpdate[key][j] = undefined; + } + } + } + + Plotly.restyle(gd, attrUpdate, attrIndices); + } +}; diff --git a/test/jasmine/tests/legend_test.js b/test/jasmine/tests/legend_test.js index e994811d6ae..fd566f9cc91 100644 --- a/test/jasmine/tests/legend_test.js +++ b/test/jasmine/tests/legend_test.js @@ -833,23 +833,27 @@ describe('legend interaction', function() { afterAll(destroyGraphDiv); function _click(index) { - var item = d3.selectAll('rect.legendtoggle')[0][index || 0]; - return new Promise(function(resolve) { - item.dispatchEvent(new MouseEvent('mousedown')); - item.dispatchEvent(new MouseEvent('mouseup')); - setTimeout(resolve, DBLCLICKDELAY + 20); - }); + return function() { + var item = d3.selectAll('rect.legendtoggle')[0][index || 0]; + return new Promise(function(resolve) { + item.dispatchEvent(new MouseEvent('mousedown')); + item.dispatchEvent(new MouseEvent('mouseup')); + setTimeout(resolve, DBLCLICKDELAY + 20); + }); + }; } function _dblclick(index) { - var item = d3.selectAll('rect.legendtoggle')[0][index || 0]; - return new Promise(function(resolve) { - item.dispatchEvent(new MouseEvent('mousedown')); - item.dispatchEvent(new MouseEvent('mouseup')); - item.dispatchEvent(new MouseEvent('mousedown')); - item.dispatchEvent(new MouseEvent('mouseup')); - setTimeout(resolve, 20); - }); + return function() { + var item = d3.selectAll('rect.legendtoggle')[0][index || 0]; + return new Promise(function(resolve) { + item.dispatchEvent(new MouseEvent('mousedown')); + item.dispatchEvent(new MouseEvent('mouseup')); + item.dispatchEvent(new MouseEvent('mousedown')); + item.dispatchEvent(new MouseEvent('mouseup')); + setTimeout(resolve, 20); + }); + }; } function assertVisible(gd, expectation) { @@ -864,19 +868,19 @@ describe('legend interaction', function() { Plotly.plot(gd, _mock).then(function() { assertVisible(gd, [true, true, true, true]); }) - .then(_click) + .then(_click(0)) .then(function() { assertVisible(gd, [true, 'legendonly', true, true]); }) - .then(_click) + .then(_click(0)) .then(function() { assertVisible(gd, [true, true, true, true]); }) - .then(_dblclick) + .then(_dblclick(0)) .then(function() { assertVisible(gd, [true, true, 'legendonly', 'legendonly']); }) - .then(_dblclick) + .then(_dblclick(0)) .then(function() { assertVisible(gd, [true, true, true, true]); }) @@ -952,4 +956,193 @@ describe('legend interaction', function() { }).catch(fail).then(done); }); }); + + describe('legend visibility interactions', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function click(index, clicks) { + return function() { + return new Promise(function(resolve) { + var item = d3.selectAll('rect.legendtoggle')[0][index || 0]; + for(var i = 0; i < (clicks || 1); i++) { + item.dispatchEvent(new MouseEvent('mousedown')); + item.dispatchEvent(new MouseEvent('mouseup')); + } + setTimeout(resolve, DBLCLICKDELAY + 20); + }); + }; + } + + function assertVisible(expectation) { + return function() { + var actual = gd._fullData.map(function(trace) { return trace.visible; }); + expect(actual).toEqual(expectation); + }; + } + + describe('for regular traces', function() { + beforeEach(function(done) { + Plotly.plot(gd, [ + {x: [1, 2], y: [0, 1], visible: false}, + {x: [1, 2], y: [1, 2], visible: 'legendonly'}, + {x: [1, 2], y: [2, 3]} + ]).then(done); + }); + + it('clicking once toggles legendonly -> true', function(done) { + Promise.resolve() + .then(assertVisible([false, 'legendonly', true])) + .then(click(0)) + .then(assertVisible([false, true, true])) + .catch(fail).then(done); + }); + + it('clicking once toggles true -> legendonly', function(done) { + Promise.resolve() + .then(assertVisible([false, 'legendonly', true])) + .then(click(1)) + .then(assertVisible([false, 'legendonly', 'legendonly'])) + .catch(fail).then(done); + }); + + it('double-clicking isolates a visible trace ', function(done) { + Promise.resolve() + .then(click(0)) + .then(assertVisible([false, true, true])) + .then(click(0, 2)) + .then(assertVisible([false, true, 'legendonly'])) + .catch(fail).then(done); + }); + + it('double-clicking an isolated trace shows all non-hidden traces', function(done) { + Promise.resolve() + .then(click(0, 2)) + .then(assertVisible([false, true, true])) + .catch(fail).then(done); + }); + }); + + describe('legendgroup visibility', function() { + beforeEach(function(done) { + Plotly.plot(gd, [{ + x: [1, 2], + y: [3, 4], + visible: false + }, { + x: [1, 2, 3, 4], + y: [0, 1, 2, 3], + legendgroup: 'foo' + }, { + x: [1, 2, 3, 4], + y: [1, 3, 2, 4], + }, { + x: [1, 2, 3, 4], + y: [1, 3, 2, 4], + legendgroup: 'foo' + }]).then(done); + }); + + it('toggles the visibility of legendgroups as a whole', function(done) { + Promise.resolve() + .then(click(1)) + .then(assertVisible([false, 'legendonly', true, 'legendonly'])) + .then(click(1)) + .then(assertVisible([false, true, true, true])) + .catch(fail).then(done); + }); + + it('isolates legendgroups as a whole', function(done) { + Promise.resolve() + .then(click(1, 2)) + .then(assertVisible([false, true, 'legendonly', true])) + .then(click(1, 2)) + .then(assertVisible([false, true, true, true])) + .catch(fail).then(done); + }); + }); + + describe('legend visibility toggles with groupby', function() { + beforeEach(function(done) { + Plotly.plot(gd, [{ + x: [1, 2], + y: [3, 4], + visible: false + }, { + x: [1, 2, 3, 4], + y: [0, 1, 2, 3] + }, { + x: [1, 2, 3, 4], + y: [1, 3, 2, 4], + transforms: [{ + type: 'groupby', + groups: ['a', 'b', 'c', 'c'] + }] + }, { + x: [1, 2, 3, 4], + y: [1, 3, 2, 4], + transforms: [{ + type: 'groupby', + groups: ['a', 'b', 'c', 'c'] + }] + }]).then(done); + }); + + it('computes the initial visibility correctly', function(done) { + Promise.resolve() + .then(assertVisible([false, true, true, true, true, true, true, true])) + .catch(fail).then(done); + }); + + it('toggles the visibility of a non-groupby trace in the presence of groupby traces', function(done) { + Promise.resolve() + .then(click(1)) + .then(assertVisible([false, true, 'legendonly', true, true, true, true, true])) + .then(click(1)) + .then(assertVisible([false, true, true, true, true, true, true, true])) + .catch(fail).then(done); + }); + + it('toggles the visibility of the first group in a groupby trace', function(done) { + Promise.resolve() + .then(click(0)) + .then(assertVisible([false, 'legendonly', true, true, true, true, true, true])) + .then(click(0)) + .then(assertVisible([false, true, true, true, true, true, true, true])) + .catch(fail).then(done); + }); + + it('toggles the visibility of the third group in a groupby trace', function(done) { + Promise.resolve() + .then(click(3)) + .then(assertVisible([false, true, true, true, 'legendonly', true, true, true])) + .then(click(3)) + .then(assertVisible([false, true, true, true, true, true, true, true])) + .catch(fail).then(done); + }); + + it('double-clicking isolates a non-groupby trace', function(done) { + Promise.resolve() + .then(click(0, 2)) + .then(assertVisible([false, true, 'legendonly', 'legendonly', 'legendonly', 'legendonly', 'legendonly', 'legendonly'])) + .then(click(0, 2)) + .then(assertVisible([false, true, true, true, true, true, true, true])) + .catch(fail).then(done); + }); + + it('double-clicking isolates a groupby trace', function(done) { + Promise.resolve() + .then(click(1, 2)) + .then(assertVisible([false, 'legendonly', true, 'legendonly', 'legendonly', 'legendonly', 'legendonly', 'legendonly'])) + .then(click(1, 2)) + .then(assertVisible([false, true, true, true, true, true, true, true])) + .catch(fail).then(done); + }); + }); + }); });