Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);

const 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;
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 {
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;
Comment on lines +234 to +235
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexshoe Is this line an unrelated bugfix for groupclick? Or is it related to the titleclick functionality?

(it's fine either way just want to clarify)

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
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) {
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
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);
}
});
Comment on lines +717 to +731
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexshoe Could some of the logic in the clickOrDoubleClick() function possibly be reused here? Seems like a fair amount of code duplication, although I'm sure there are some subtle differences.

}

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 });
}
Comment on lines +734 to +752
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, likewise this function seems like it's duplicating a lot of the title placement logic. I don't think we should be referencing parameters like legendObj.title.side here at all. My feeling is that the titleToggle logic should be more parallel to the traceToggle placement logic.


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;

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

const legendId = legendItem.trace.legend || 'legend';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, this fixes a bug where 'secondary' legends (e.g. legend2, legend3, etc.) used the click settings of legend rather than using their own settings, is that right? I've tried it out in Codepen and that seems to be the case.

I believe that fixes this issue, can you confirm @alexshoe ? If so, please tag in PR description.

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
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) {
const fullLayout = gd._fullLayout;
const fullData = gd._fullData;
const legendId = legendObj._id || 'legend';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps move the getId() function in src/components/legend/draw.js to helpers.js and use it here, since it does exactly this

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