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

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() {
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';
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';
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