diff --git a/src/components/legend/attributes.js b/src/components/legend/attributes.js index 0d7f0d49e9f..6a08a1bb31e 100644 --- a/src/components/legend/attributes.js +++ b/src/components/legend/attributes.js @@ -176,6 +176,30 @@ module.exports = { '*togglegroup* toggles the visibility of all items in the same legendgroup as the item clicked on the graph.' ].join(' ') }, + titleclick: { + valType: 'enumerated', + values: ['toggle', 'toggleothers', false], + editType: 'legend', + description: [ + 'Determines the behavior on legend title click.', + '*toggle* toggles the visibility of all items in the legend.', + '*toggleothers* toggles the visibility of all other legends.', + '*false* disables legend title click interactions.', + 'Defaults to *toggle* when there are multiple legends, *false* otherwise.' + ].join(' ') + }, + titledoubleclick: { + valType: 'enumerated', + values: ['toggle', 'toggleothers', false], + editType: 'legend', + description: [ + 'Determines the behavior on legend title double-click.', + '*toggle* toggles the visibility of all items in the legend.', + '*toggleothers* toggles the visibility of all other legends.', + '*false* disables legend title double-click interactions.', + 'Defaults to *toggleothers* when there are multiple legends, *false* otherwise.' + ].join(' ') + }, x: { valType: 'number', editType: 'legend', diff --git a/src/components/legend/defaults.js b/src/components/legend/defaults.js index f8be07461f4..eb8a6212a0f 100644 --- a/src/components/legend/defaults.js +++ b/src/components/legend/defaults.js @@ -9,7 +9,7 @@ var attributes = require('./attributes'); var basePlotLayoutAttributes = require('../../plots/layout_attributes'); var helpers = require('./helpers'); -function groupDefaults(legendId, layoutIn, layoutOut, fullData) { +function groupDefaults(legendId, layoutIn, layoutOut, fullData, legendCount) { var containerIn = layoutIn[legendId] || {}; var containerOut = Template.newContainer(layoutOut, legendId); @@ -238,6 +238,10 @@ function groupDefaults(legendId, layoutIn, layoutOut, fullData) { }); Lib.coerceFont(coerce, 'title.font', dfltTitleFont); + + const hasMultipleLegends = legendCount > 1; + coerce('titleclick', hasMultipleLegends ? 'toggle' : false); + coerce('titledoubleclick', hasMultipleLegends ? 'toggleothers' : false); } } @@ -277,7 +281,7 @@ module.exports = function legendDefaults(layoutIn, layoutOut, fullData) { for(i = 0; i < legends.length; i++) { var legendId = legends[i]; - groupDefaults(legendId, layoutIn, layoutOut, allLegendsData); + groupDefaults(legendId, layoutIn, layoutOut, allLegendsData, legends.length); if(layoutOut[legendId]) { layoutOut[legendId]._id = legendId; diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js index 49c793d3f12..5ac11454c57 100644 --- a/src/components/legend/draw.js +++ b/src/components/legend/draw.js @@ -10,7 +10,8 @@ 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 handleClick = require('./handle_click').handleClick; +var handleTitleClick = require('./handle_click').handleTitleClick; var constants = require('./constants'); var alignmentConstants = require('../../constants/alignment'); @@ -180,8 +181,14 @@ function drawOne(gd, opts) { .text(title.text); textLayout(titleEl, scrollBox, gd, legendObj, MAIN_TITLE); // handle mathjax or multi-line text and compute title height + + // Set up title click if enabled and not in hover mode + if(!inHover && (legendObj.titleclick || legendObj.titledoubleclick)) { + setupTitleToggle(scrollBox, gd, legendObj, legendId); + } } else { scrollBox.selectAll('.' + legendId + 'titletext').remove(); + scrollBox.selectAll('.' + legendId + 'titletoggle').remove(); } var scrollBar = Lib.ensureSingle(legend, 'rect', 'scrollbar', function(s) { @@ -198,7 +205,22 @@ function drawOne(gd, opts) { traces.exit().remove(); traces.style('opacity', function(d) { - var trace = d[0].trace; + const legendItem = d[0]; + const trace = legendItem.trace; + + // Toggle opacity of legend group titles if all items in the group are hidden + if(legendItem.groupTitle) { + const groupName = trace.legendgroup; + const shapes = (fullLayout.shapes || []).filter(function(s) { return s.showlegend; }); + const anyVisible = gd._fullData.concat(shapes).some(function(item) { + return item.legendgroup === groupName && + (item.legend || 'legend') === legendId && + item.visible === true; + }); + + return anyVisible ? 1 : 0.5; + } + if(Registry.traceIs(trace, 'pie-like')) { return hiddenSlices.indexOf(d[0].label) !== -1 ? 0.5 : 1; } else { @@ -207,7 +229,12 @@ function drawOne(gd, opts) { }) .each(function() { d3.select(this).call(drawTexts, gd, legendObj); }) .call(style, gd, legendObj) - .each(function() { if(!inHover) d3.select(this).call(setupTraceToggle, gd, legendId); }); + .each(function(d) { + if(inHover) return; + // Don't create a click targets for group titles when groupclick is 'toggleitem' + if(d[0].groupTitle && legendObj.groupclick === 'toggleitem') return; + d3.select(this).call(setupTraceToggle, gd, legendId); + }); Lib.syncOrAsync([ Plots.previousPromises, @@ -221,6 +248,20 @@ function drawOne(gd, opts) { // re-calculate title position after legend width is derived. To allow for horizontal alignment if(title.text) { horizontalAlignTitle(titleEl, legendObj, bw); + + // Position click target for the title after dimensions are computed + if(!inHover && (legendObj.titleclick || legendObj.titledoubleclick)) { + positionTitleToggle(scrollBox, legendObj, legendId); + } + + // Toggle opacity of legend titles if all items in the legend are hidden + const shapes = (fullLayout.shapes || []).filter(function(s) { return s.showlegend; }); + const anyVisible = gd._fullData.concat(shapes).some(function(item) { + const inThisLegend = (item.legend || 'legend') === legendId; + return inThisLegend && item.visible === true; + }); + + titleEl.style('opacity', anyVisible ? 1 : 0.5); } if(!inHover) { @@ -624,6 +665,92 @@ function setupTraceToggle(g, gd, legendId) { }); } +function setupTitleToggle(scrollBox, gd, legendObj, legendId) { + // For now, skip title click for legends containing pie-like traces + const hasPie = gd._fullData.some(function(trace) { + const legend = trace.legend || 'legend'; + const inThisLegend = Array.isArray(legend) ? legend.includes(legendId) : legend === legendId; + return inThisLegend && Registry.traceIs(trace, 'pie-like'); + }); + if(hasPie) return; + + const doubleClickDelay = gd._context.doubleClickDelay; + var newMouseDownTime; + var numClicks = 1; + + const titleToggle = Lib.ensureSingle(scrollBox, 'rect', legendId + 'titletoggle', function(s) { + if(!gd._context.staticPlot) { + s.style('cursor', 'pointer').attr('pointer-events', 'all'); + } + s.call(Color.fill, 'rgba(0,0,0,0)'); + }); + + if(gd._context.staticPlot) return; + + titleToggle.on('mousedown', function() { + newMouseDownTime = (new Date()).getTime(); + if(newMouseDownTime - gd._legendMouseDownTime < doubleClickDelay) { + // in a click train + numClicks += 1; + } else { + // new click train + numClicks = 1; + gd._legendMouseDownTime = newMouseDownTime; + } + }); + titleToggle.on('mouseup', function() { + if(gd._dragged || gd._editing) return; + + if((new Date()).getTime() - gd._legendMouseDownTime > doubleClickDelay) { + numClicks = Math.max(numClicks - 1, 1); + } + + const evtData = { + event: d3.event, + legendId: legendId, + data: gd.data, + layout: gd.layout, + fullData: gd._fullData, + fullLayout: gd._fullLayout + }; + + if(numClicks === 1 && legendObj.titleclick) { + const clickVal = Events.triggerHandler(gd, 'plotly_legendtitleclick', evtData); + if(clickVal === false) return; + + legendObj._titleClickTimeout = setTimeout(function() { + if(gd._fullLayout) handleTitleClick(gd, legendObj, legendObj.titleclick); + }, doubleClickDelay); + } else if(numClicks === 2) { + if(legendObj._titleClickTimeout) clearTimeout(legendObj._titleClickTimeout); + gd._legendMouseDownTime = 0; + + const dblClickVal = Events.triggerHandler(gd, 'plotly_legendtitledoubleclick', evtData); + if(dblClickVal !== false && legendObj.titledoubleclick) handleTitleClick(gd, legendObj, legendObj.titledoubleclick); + } + }); +} + +function positionTitleToggle(scrollBox, legendObj, legendId) { + const titleToggle = scrollBox.select('.' + legendId + 'titletoggle'); + if(!titleToggle.size()) return; + + const side = legendObj.title.side || 'top'; + const bw = legendObj.borderwidth; + var x = bw; + const width = legendObj._titleWidth + 2 * constants.titlePad; + const height = legendObj._titleHeight + 2 * constants.titlePad; + + + if(side === 'top center') { + x = bw + 0.5 * (legendObj._width - 2 * bw - width); + } else if(side === 'top right') { + x = legendObj._width - bw - width; + } + + titleToggle.attr({ x: x, y: bw, width: width, height: height }); +} + function textLayout(s, g, gd, legendObj, aTitle) { if(legendObj._inHover) s.attr('data-notex', true); // do not process MathJax for unified hover svgTextUtils.convertToTspans(s, gd, function() { diff --git a/src/components/legend/handle_click.js b/src/components/legend/handle_click.js index 6c2b7b81b45..3f4302d972b 100644 --- a/src/components/legend/handle_click.js +++ b/src/components/legend/handle_click.js @@ -6,14 +6,21 @@ var pushUnique = Lib.pushUnique; var SHOWISOLATETIP = true; -module.exports = function handleClick(g, gd, numClicks) { +exports.handleClick = function handleClick(g, gd, numClicks) { var fullLayout = gd._fullLayout; if(gd._dragged || gd._editing) return; - var itemClick = fullLayout.legend.itemclick; - var itemDoubleClick = fullLayout.legend.itemdoubleclick; - var groupClick = fullLayout.legend.groupclick; + + const legendItem = g.data()[0][0]; + if(legendItem.groupTitle && legendItem.noClick) return; + + const legendId = legendItem.trace.legend || 'legend'; + const legendObj = fullLayout[legendId]; + + const itemClick = legendObj.itemclick; + const itemDoubleClick = legendObj.itemdoubleclick; + const groupClick = legendObj.groupclick; if(numClicks === 1 && itemClick === 'toggle' && itemDoubleClick === 'toggleothers' && SHOWISOLATETIP && gd.data && gd._context.showTips @@ -35,9 +42,6 @@ module.exports = function handleClick(g, gd, numClicks) { fullLayout.hiddenlabels.slice() : []; - var legendItem = g.data()[0][0]; - if(legendItem.groupTitle && legendItem.noClick) return; - var fullData = gd._fullData; var shapesWithLegend = (fullLayout.shapes || []).filter(function(d) { return d.showlegend; }); var allLegendItems = fullData.concat(shapesWithLegend); @@ -269,3 +273,64 @@ module.exports = function handleClick(g, gd, numClicks) { } } }; + +exports.handleTitleClick = function handleTitleClick(gd, legendObj, mode) { + const fullLayout = gd._fullLayout; + const fullData = gd._fullData; + const legendId = legendObj._id || 'legend'; + const shapesWithLegend = (fullLayout.shapes || []).filter(function(d) { return d.showlegend; }); + const allLegendItems = fullData.concat(shapesWithLegend); + + function isInLegend(item) { + return (item.legend || 'legend') === legendId; + } + + var toggleThisLegend; + var toggleOtherLegends; + + if(mode === 'toggle') { + // If any item is visible in this legend, hide all. If all are hidden, show all + const anyVisibleHere = allLegendItems.some(function(item) { + return isInLegend(item) && item.visible === true; + }); + + toggleThisLegend = !anyVisibleHere; + toggleOtherLegends = null; + } else { + // isolate this legend or set all legends to visible + const anyVisibleElsewhere = allLegendItems.some(function(item) { + return !isInLegend(item) && item.visible === true && item.showlegend !== false; + }); + + toggleThisLegend = true; + toggleOtherLegends = !anyVisibleElsewhere; + } + + const dataUpdate = { visible: [] }; + const dataIndices = []; + const updatedShapes = (fullLayout.shapes || []).map(function(d) { return d._input; }); + var shapesUpdated = false; + + for(var i = 0; i < allLegendItems.length; i++) { + const item = allLegendItems[i]; + const shouldShow = isInLegend(item) ? toggleThisLegend : toggleOtherLegends; + const newVis = shouldShow ? true : 'legendonly'; + + // Only update if the item is visible and the visibility is different from the new visibility + if ((item.visible !== false) && (shouldShow !== null) && (item.visible !== newVis)) { + if(item._isShape) { + updatedShapes[item._index].visible = newVis; + shapesUpdated = true; + } else { + dataIndices.push(item.index); + dataUpdate.visible.push(newVis); + } + } + } + + if(shapesUpdated) { + Registry.call('_guiUpdate', gd, dataUpdate, {shapes: updatedShapes}, dataIndices); + } else if(dataIndices.length) { + Registry.call('_guiRestyle', gd, dataUpdate, dataIndices); + } +}; diff --git a/test/jasmine/tests/legend_test.js b/test/jasmine/tests/legend_test.js index 9fc21aabb47..a6099aafd80 100644 --- a/test/jasmine/tests/legend_test.js +++ b/test/jasmine/tests/legend_test.js @@ -2794,3 +2794,250 @@ describe('legend with custom legendwidth', function() { }).then(done, done.fail); }); }); + +describe('legend title click', function() { + "use strict"; + + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + afterEach(destroyGraphDiv); + + function clickTitle(legendId, clicks) { + return function() { + return new Promise(function(resolve) { + var selector = '.' + (legendId || 'legend') + 'titletoggle'; + var item = d3Select(selector).node(); + if(!item) { + fail('Could not find title toggle element: ' + selector); + return resolve(); + } + for(var i = 0; i < (clicks || 1); i++) { + item.dispatchEvent(new MouseEvent('mousedown')); + item.dispatchEvent(new MouseEvent('mouseup')); + } + setTimeout(resolve, DBLCLICKDELAY + 100); + }); + }; + } + + function extractVisibilities(data) { + return data.map(function(trace) { return trace.visible; }); + } + + function assertVisible(expectation) { + return function() { + var actual = extractVisibilities(gd._fullData); + expect(actual).toEqual(expectation); + }; + } + + function assertVisibleShapes(expectation) { + return function() { + var actual = extractVisibilities(gd._fullLayout.shapes); + expect(actual).toEqual(expectation); + }; + } + + describe('defaults', function() { + it('should disable title clicking by default for a single legend', function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] } + ], { + legend: { title: { text: 'Legend' } } + }).then(function() { + expect(gd._fullLayout.legend.titleclick).toBe(false); + expect(gd._fullLayout.legend.titledoubleclick).toBe(false); + }).then(done, done.fail); + }); + + it('should enable title clicking by default for multiple legends', function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] }, + { x: [1, 2], y: [3, 4], legend: 'legend2' }, + { x: [1, 2], y: [4, 5], legend: 'legend2' } + ], { + showlegend: true, + legend: { title: { text: 'Legend 1' } }, + legend2: { title: { text: 'Legend 2' } } + }).then(function() { + expect(gd._fullLayout.legend.titleclick).toBe('toggle'); + expect(gd._fullLayout.legend.titledoubleclick).toBe('toggleothers'); + expect(gd._fullLayout.legend2.titleclick).toBe('toggle'); + expect(gd._fullLayout.legend2.titledoubleclick).toBe('toggleothers'); + }).then(done, done.fail); + }); + + it('should allow user to override titleclick and titledoubleclick', function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] } + ], { + legend: { + title: { text: 'Legend' }, + titleclick: 'toggle', + titledoubleclick: false + } + }).then(function() { + expect(gd._fullLayout.legend.titleclick).toBe('toggle'); + expect(gd._fullLayout.legend.titledoubleclick).toBe(false); + }).then(done, done.fail); + }); + }); + + describe('toggle interactions', function() { + beforeEach(function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] }, + { x: [1, 2], y: [3, 4], legend: 'legend2' }, + { x: [1, 2], y: [4, 5], legend: 'legend2' } + ], { + showlegend: true, + legend: { title: { text: 'Legend 1' } }, + legend2: { title: { text: 'Legend 2' }, y: 0.5 } + }).then(done); + }); + + it('should hide all traces in legend when clicking title (all visible)', function(done) { + Promise.resolve() + .then(assertVisible([true, true, true, true])) + .then(clickTitle('legend')) + .then(assertVisible(['legendonly', 'legendonly', true, true])) + .then(done, done.fail); + }); + + it('should show all traces in legend when clicking title (all hidden)', function(done) { + Plotly.restyle(gd, 'visible', 'legendonly', [0, 1]) + .then(assertVisible(['legendonly', 'legendonly', true, true])) + .then(clickTitle('legend')) + .then(assertVisible([true, true, true, true])) + .then(done, done.fail); + }); + + it('should not affect traces with visible: false', function(done) { + Plotly.restyle(gd, 'visible', false, [0]) + .then(assertVisible([false, true, true, true])) + .then(clickTitle('legend')) + .then(assertVisible([false, 'legendonly', true, true])) + .then(done, done.fail); + }); + }); + + describe('toggleothers interactions', function() { + beforeEach(function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] }, + { x: [1, 2], y: [3, 4], legend: 'legend2' }, + { x: [1, 2], y: [4, 5], legend: 'legend2' } + ], { + showlegend: true, + legend: { title: { text: 'Legend 1' } }, + legend2: { title: { text: 'Legend 2' }, y: 0.5 } + }).then(done); + }); + + it('should isolate this legend (hide others)', function(done) { + Promise.resolve() + .then(assertVisible([true, true, true, true])) + .then(clickTitle('legend', 2)) + .then(assertVisible([true, true, 'legendonly', 'legendonly'])) + .then(done, done.fail); + }); + + it('should restore all when already isolated', function(done) { + Plotly.restyle(gd, 'visible', 'legendonly', [2, 3]) + .then(assertVisible([true, true, 'legendonly', 'legendonly'])) + .then(clickTitle('legend', 2)) + .then(assertVisible([true, true, true, true])) + .then(done, done.fail); + }); + }); + + describe('interactions with shapes', function() { + beforeEach(function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] }, + { x: [1, 2], y: [3, 4], legend: 'legend2' }, + { x: [1, 2], y: [4, 5], legend: 'legend2' } + ], { + showlegend: true, + legend: { title: { text: 'Legend 1' } }, + legend2: { title: { text: 'Legend 2' }, y: 0.5 }, + shapes: [ + { showlegend: true, type: 'line', x0: 0, y0: 0, x1: 1, y1: 1 }, + { showlegend: true, type: 'rect', x0: 0, y0: 0, x1: 1, y1: 1, legend: 'legend2' } + ] + }).then(done); + }); + + it('should toggle shapes with traces', function(done) { + Promise.resolve() + .then(assertVisible([true, true, true, true])) + .then(assertVisibleShapes([true, true])) + .then(clickTitle('legend')) + .then(assertVisible(['legendonly', 'legendonly', true, true])) + .then(assertVisibleShapes(['legendonly', true])) + .then(done, done.fail); + }); + }); + + it('should not create click target when no title text', function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] }, + { x: [1, 2], y: [3, 4], legend: 'legend2' }, + { x: [1, 2], y: [4, 5], legend: 'legend2' } + ], { + showlegend: true, + legend: {}, + legend2: { y: 0.5 } + }).then(function() { + var titleToggle = d3Select('.legendtitletoggle'); + expect(titleToggle.size()).toBe(0); + }).then(done, done.fail); + }); + + it('should have a pointer cursor on hover for clickable titles', function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] }, + { x: [1, 2], y: [3, 4], legend: 'legend2' }, + { x: [1, 2], y: [4, 5], legend: 'legend2' } + ], { + showlegend: true, + legend: { title: { text: 'Legend 1' } }, + legend2: { title: { text: 'Legend 2' }, y: 0.5 } + }).then(function() { + var titleToggle = d3Select('.legendtitletoggle').node(); + expect(titleToggle.style.cursor).toBe('pointer'); + }).then(done, done.fail); + }); + + it('should not have pointer cursor on static plots', function(done) { + Plotly.newPlot(gd, [ + { x: [1, 2], y: [1, 2] }, + { x: [1, 2], y: [2, 3] }, + { x: [1, 2], y: [3, 4], legend: 'legend2' }, + { x: [1, 2], y: [4, 5], legend: 'legend2' } + ], { + showlegend: true, + legend: { title: { text: 'Legend 1' } }, + legend2: { title: { text: 'Legend 2' }, y: 0.5 } + }, { + staticPlot: true + }).then(function() { + var titleToggle = d3Select('.legendtitletoggle').node(); + // On static plots, the title toggle rect is created but without pointer cursor + if(titleToggle) { + expect(titleToggle.style.cursor).not.toBe('pointer'); + } + }).then(done, done.fail); + }); +}); \ No newline at end of file diff --git a/test/plot-schema.json b/test/plot-schema.json index 211da680a56..e753455cb7f 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -3581,6 +3581,26 @@ "valType": "string" } }, + "titleclick": { + "description": "Determines the behavior on legend title click. *toggle* toggles the visibility of all items in the legend. *toggleothers* toggles the visibility of all other legends. *false* disables legend title click interactions. Defaults to *toggle* when there are multiple legends, *false* otherwise.", + "editType": "legend", + "valType": "enumerated", + "values": [ + "toggle", + "toggleothers", + false + ] + }, + "titledoubleclick": { + "description": "Determines the behavior on legend title double-click. *toggle* toggles the visibility of all items in the legend. *toggleothers* toggles the visibility of all other legends. *false* disables legend title double-click interactions. Defaults to *toggleothers* when there are multiple legends, *false* otherwise.", + "editType": "legend", + "valType": "enumerated", + "values": [ + "toggle", + "toggleothers", + false + ] + }, "tracegroupgap": { "description": "Sets the amount of vertical space (in px) between legend groups.", "dflt": 10,