Skip to content
Open
24 changes: 24 additions & 0 deletions src/components/legend/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 6 additions & 2 deletions src/components/legend/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -238,6 +238,10 @@ function groupDefaults(legendId, layoutIn, layoutOut, fullData) {
});

Lib.coerceFont(coerce, 'title.font', dfltTitleFont);

var hasMultipleLegends = legendCount > 1;
coerce('titleclick', hasMultipleLegends ? 'toggle' : false);
coerce('titledoubleclick', hasMultipleLegends ? 'toggleothers' : false);
}
}

Expand Down Expand Up @@ -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;
Expand Down
133 changes: 130 additions & 3 deletions src/components/legend/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand All @@ -198,7 +205,22 @@ function drawOne(gd, opts) {
traces.exit().remove();

traces.style('opacity', function(d) {
var trace = d[0].trace;
var legendItem = d[0];
var trace = legendItem.trace;

// Toggle opacity of legend group titles if all items in the group are hidden
if(legendItem.groupTitle) {
var groupName = trace.legendgroup;
var shapes = (fullLayout.shapes || []).filter(function(s) { return s.showlegend; });
var 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 {
Expand All @@ -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,
Expand All @@ -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
var shapes = (fullLayout.shapes || []).filter(function(s) { return s.showlegend; });
var anyVisible = gd._fullData.concat(shapes).some(function(item) {
var inThisLegend = (item.legend || 'legend') === legendId;
return inThisLegend && item.visible === true;
});

titleEl.style('opacity', anyVisible ? 1 : 0.5);
}

if(!inHover) {
Expand Down Expand Up @@ -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
var hasPie = gd._fullData.some(function(trace) {
var legend = trace.legend || 'legend';
var inThisLegend = Array.isArray(legend) ? legend.includes(legendId) : legend === legendId;
return inThisLegend && Registry.traceIs(trace, 'pie-like');
});
if(hasPie) return;

var doubleClickDelay = gd._context.doubleClickDelay;
var newMouseDownTime;
var numClicks = 1;

var 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);
}

var evtData = {
event: d3.event,
legendId: legendId,
data: gd.data,
layout: gd.layout,
fullData: gd._fullData,
fullLayout: gd._fullLayout
};

if(numClicks === 1 && legendObj.titleclick) {
var 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;

var dblClickVal = Events.triggerHandler(gd, 'plotly_legendtitledoubleclick', evtData);
if(dblClickVal !== false && legendObj.titledoubleclick) handleTitleClick(gd, legendObj, legendObj.titledoubleclick);
}
});
}

function positionTitleToggle(scrollBox, legendObj, legendId) {
var titleToggle = scrollBox.select('.' + legendId + 'titletoggle');
if(!titleToggle.size()) return;

var side = legendObj.title.side || 'top';
var bw = legendObj.borderwidth;
var x = bw;
var width = legendObj._titleWidth + 2 * constants.titlePad;
var 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() {
Expand Down
79 changes: 72 additions & 7 deletions src/components/legend/handle_click.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

var legendItem = g.data()[0][0];
if(legendItem.groupTitle && legendItem.noClick) return;

var legendId = legendItem.trace.legend || 'legend';
var legendObj = fullLayout[legendId];

var itemClick = legendObj.itemclick;
var itemDoubleClick = legendObj.itemdoubleclick;
var groupClick = legendObj.groupclick;

if(numClicks === 1 && itemClick === 'toggle' && itemDoubleClick === 'toggleothers' &&
SHOWISOLATETIP && gd.data && gd._context.showTips
Expand All @@ -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);
Expand Down Expand Up @@ -269,3 +273,64 @@ module.exports = function handleClick(g, gd, numClicks) {
}
}
};

exports.handleTitleClick = function handleTitleClick(gd, legendObj, mode) {
var fullLayout = gd._fullLayout;
var fullData = gd._fullData;
var legendId = legendObj._id || 'legend';
var shapesWithLegend = (fullLayout.shapes || []).filter(function(d) { return d.showlegend; });
var 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
var 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
var anyVisibleElsewhere = allLegendItems.some(function(item) {
return !isInLegend(item) && item.visible === true && item.showlegend !== false;
});

toggleThisLegend = true;
toggleOtherLegends = !anyVisibleElsewhere;
}

var dataUpdate = { visible: [] };
var dataIndices = [];
var updatedShapes = (fullLayout.shapes || []).map(function(d) { return d._input; });
var shapesUpdated = false;

for(var i = 0; i < allLegendItems.length; i++) {
var item = allLegendItems[i];
var shouldShow = isInLegend(item) ? toggleThisLegend : toggleOtherLegends;
var 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);
}
};
Binary file added test/image/baselines/legend_title_click.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading