Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draw multiple legends on a graph #6535

Merged
merged 15 commits into from
Apr 24, 2023
Merged
3 changes: 3 additions & 0 deletions draftlogs/6535_add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- Add `legend` references to traces and `legend2`, `legend3`, etc. to layout
to allow positioning multiple legends on a graph [[#6535](https://github.com/plotly/plotly.js/pull/6535)],
this feature was anonymously sponsored: thank you to our sponsor!
14 changes: 14 additions & 0 deletions src/components/legend/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ var colorAttrs = require('../color/attributes');


module.exports = {
// not really a 'subplot' attribute container,
// but this is the flag we use to denote attributes that
// support yaxis, yaxis2, yaxis3, ... counters
_isSubplotObj: true,

visible: {
valType: 'boolean',
dflt: true,
editType: 'legend',
description: [
'Determines whether or not this legend is visible.'
].join(' ')
},

bgcolor: {
valType: 'color',
editType: 'legend',
Expand Down
43 changes: 36 additions & 7 deletions src/components/legend/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,22 @@ var attributes = require('./attributes');
var basePlotLayoutAttributes = require('../../plots/layout_attributes');
var helpers = require('./helpers');


module.exports = function legendDefaults(layoutIn, layoutOut, fullData) {
var containerIn = layoutIn.legend || {};
var containerOut = Template.newContainer(layoutOut, 'legend');
function groupDefaults(legendId, layoutIn, layoutOut, fullData) {
var containerIn = layoutIn[legendId] || {};
var containerOut = Template.newContainer(layoutOut, legendId);

function coerce(attr, dflt) {
return Lib.coerce(containerIn, containerOut, attributes, attr, dflt);
}

// N.B. unified hover needs to inherit from font, bgcolor & bordercolor even when legend.visible is false
var itemFont = Lib.coerceFont(coerce, 'font', layoutOut.font);
coerce('bgcolor', layoutOut.paper_bgcolor);
coerce('bordercolor');

var visible = coerce('visible');
if(!visible) return;

var trace;
var traceCoerce = function(attr, dflt) {
var traceIn = trace._input;
Expand Down Expand Up @@ -91,10 +98,7 @@ module.exports = function legendDefaults(layoutIn, layoutOut, fullData) {

if(showLegend === false) return;

coerce('bgcolor', layoutOut.paper_bgcolor);
coerce('bordercolor');
coerce('borderwidth');
var itemFont = Lib.coerceFont(coerce, 'font', layoutOut.font);

var orientation = coerce('orientation');
var isHorizontal = orientation === 'h';
Expand Down Expand Up @@ -147,4 +151,29 @@ module.exports = function legendDefaults(layoutIn, layoutOut, fullData) {

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

module.exports = function legendDefaults(layoutIn, layoutOut, fullData) {
var i;
var legends = ['legend'];

for(i = 0; i < fullData.length; i++) {
Lib.pushUnique(legends, fullData[i].legend);
}

layoutOut._legends = [];
for(i = 0; i < legends.length; i++) {
var legendId = legends[i];

groupDefaults(legendId, layoutIn, layoutOut, fullData);

if(
layoutOut[legendId] &&
layoutOut[legendId].visible
) {
layoutOut[legendId]._id = legendId;
}

layoutOut._legends.push(legendId);
}
};
115 changes: 81 additions & 34 deletions src/components/legend/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,32 +24,61 @@ var helpers = require('./helpers');

var MAIN_TITLE = 1;

var LEGEND_PATTERN = /^legend[0-9]*$/;

module.exports = function draw(gd, opts) {
if(!opts) opts = gd._fullLayout.legend || {};
return _draw(gd, opts);
if(opts) {
drawOne(gd, opts);
} else {
var fullLayout = gd._fullLayout;
var newLegends = fullLayout._legends;

// remove old legends that won't stay on the graph
var oldLegends = fullLayout._infolayer.selectAll('[class^="legend"]');

oldLegends.each(function() {
var el = d3.select(this);
var classes = el.attr('class');
var cls = classes.split(' ')[0];
if(cls.match(LEGEND_PATTERN) && newLegends.indexOf(cls) === -1) {
el.remove();
}
});

// draw/update new legends
for(var i = 0; i < newLegends.length; i++) {
var legendId = newLegends[i];
var legendObj = gd._fullLayout[legendId];
drawOne(gd, legendObj);
alexcjohnson marked this conversation as resolved.
Show resolved Hide resolved
}
}
};

function _draw(gd, legendObj) {
function drawOne(gd, opts) {
var legendObj = opts || {};

var fullLayout = gd._fullLayout;
var clipId = 'legend' + fullLayout._uid;
var layer;
var legendId = getId(legendObj);

var clipId, layer;

var inHover = legendObj._inHover;
if(inHover) {
layer = legendObj.layer;
clipId += '-hover';
clipId = 'hover';
} else {
layer = fullLayout._infolayer;
clipId = legendId;
alexcjohnson marked this conversation as resolved.
Show resolved Hide resolved
}

if(!layer) return;
clipId += fullLayout._uid;

if(!gd._legendMouseDownTime) gd._legendMouseDownTime = 0;

var legendData;
if(!inHover) {
if(!gd.calcdata) return;
legendData = fullLayout.showlegend && getLegendData(gd.calcdata, legendObj);
legendData = fullLayout.showlegend && getLegendData(gd.calcdata, legendObj, fullLayout._legends.length > 1);
} else {
if(!legendObj.entries) return;
legendData = getLegendData(legendObj.entries, legendObj);
Expand All @@ -58,12 +87,12 @@ function _draw(gd, legendObj) {
var hiddenSlices = fullLayout.hiddenlabels || [];

if(!inHover && (!fullLayout.showlegend || !legendData.length)) {
layer.selectAll('.legend').remove();
layer.selectAll('.' + legendId).remove();
fullLayout._topdefs.select('#' + clipId).remove();
return Plots.autoMargin(gd, 'legend');
return Plots.autoMargin(gd, legendId);
}

var legend = Lib.ensureSingle(layer, 'g', 'legend', function(s) {
var legend = Lib.ensureSingle(layer, 'g', legendId, function(s) {
if(!inHover) s.attr('pointer-events', 'all');
});

Expand All @@ -84,14 +113,14 @@ function _draw(gd, legendObj) {
legendObj._titleWidth = 0;
legendObj._titleHeight = 0;
if(title.text) {
var titleEl = Lib.ensureSingle(scrollBox, 'text', 'legendtitletext');
var titleEl = Lib.ensureSingle(scrollBox, 'text', legendId + 'titletext');
titleEl.attr('text-anchor', 'start')
.call(Drawing.font, title.font)
.text(title.text);

textLayout(titleEl, scrollBox, gd, legendObj, MAIN_TITLE); // handle mathjax or multi-line text and compute title height
} else {
scrollBox.selectAll('.legendtitletext').remove();
scrollBox.selectAll('.' + legendId + 'titletext').remove();
}

var scrollBar = Lib.ensureSingle(legend, 'rect', 'scrollbar', function(s) {
Expand All @@ -117,7 +146,7 @@ function _draw(gd, legendObj) {
})
.each(function() { d3.select(this).call(drawTexts, gd, legendObj); })
.call(style, gd, legendObj)
.each(function() { if(!inHover) d3.select(this).call(setupTraceToggle, gd); });
.each(function() { if(!inHover) d3.select(this).call(setupTraceToggle, gd, legendId); });

Lib.syncOrAsync([
Plots.previousPromises,
Expand All @@ -127,7 +156,7 @@ function _draw(gd, legendObj) {
var bw = legendObj.borderwidth;

if(!inHover) {
var expMargin = expandMargin(gd);
var expMargin = expandMargin(gd, legendId);

// IF expandMargin return a Promise (which is truthy),
// we're under a doAutoMargin redraw, so we don't have to
Expand All @@ -145,10 +174,10 @@ function _draw(gd, legendObj) {
ly = Lib.constrain(ly, 0, fullLayout.height - legendObj._effHeight);

if(lx !== lx0) {
Lib.log('Constrain legend.x to make legend fit inside graph');
Lib.log('Constrain ' + legendId + '.x to make legend fit inside graph');
}
if(ly !== ly0) {
Lib.log('Constrain legend.y to make legend fit inside graph');
Lib.log('Constrain ' + legendId + '.y to make legend fit inside graph');
}
}

Expand Down Expand Up @@ -294,7 +323,7 @@ function _draw(gd, legendObj) {
}

function scrollHandler(scrollBoxY, scrollBarHeight, scrollRatio) {
legendObj._scrollY = gd._fullLayout.legend._scrollY = scrollBoxY;
legendObj._scrollY = gd._fullLayout[legendId]._scrollY = scrollBoxY;
Drawing.setTranslate(scrollBox, 0, -scrollBoxY);

Drawing.setRect(
Expand Down Expand Up @@ -330,11 +359,14 @@ function _draw(gd, legendObj) {
},
doneFn: function() {
if(xf !== undefined && yf !== undefined) {
Registry.call('_guiRelayout', gd, {'legend.x': xf, 'legend.y': yf});
var obj = {};
obj[legendId + '.x'] = xf;
obj[legendId + '.y'] = yf;
Registry.call('_guiRelayout', gd, obj);
}
},
clickFn: function(numClicks, e) {
var clickedTrace = layer.selectAll('g.traces').filter(function() {
var clickedTrace = groups.selectAll('g.traces').filter(function() {
var bbox = this.getBoundingClientRect();
return (
e.clientX >= bbox.left && e.clientX <= bbox.right &&
Expand Down Expand Up @@ -402,6 +434,7 @@ function clickOrDoubleClick(gd, legend, legendItem, numClicks, evt) {
}

function drawTexts(g, gd, legendObj) {
var legendId = getId(legendObj);
var legendItem = g.data()[0][0];
var trace = legendItem.trace;
var isPieLike = Registry.traceIs(trace, 'pie-like');
Expand All @@ -424,7 +457,7 @@ function drawTexts(g, gd, legendObj) {
}
}

var textEl = Lib.ensureSingle(g, 'text', 'legendtext');
var textEl = Lib.ensureSingle(g, 'text', legendId + 'text');

textEl.attr('text-anchor', 'start')
.call(Drawing.font, font)
Expand Down Expand Up @@ -478,12 +511,12 @@ function ensureLength(str, maxLength) {
return str;
}

function setupTraceToggle(g, gd) {
function setupTraceToggle(g, gd, legendId) {
var doubleClickDelay = gd._context.doubleClickDelay;
var newMouseDownTime;
var numClicks = 1;

var traceToggle = Lib.ensureSingle(g, 'rect', 'legendtoggle', function(s) {
var traceToggle = Lib.ensureSingle(g, 'rect', legendId + 'toggle', function(s) {
if(!gd._context.staticPlot) {
s.style('cursor', 'pointer').attr('pointer-events', 'all');
}
Expand All @@ -505,7 +538,7 @@ function setupTraceToggle(g, gd) {
});
traceToggle.on('mouseup', function() {
if(gd._dragged || gd._editing) return;
var legend = gd._fullLayout.legend;
var legend = gd._fullLayout[legendId];

if((new Date()).getTime() - gd._legendMouseDownTime > doubleClickDelay) {
numClicks = Math.max(numClicks - 1, 1);
Expand All @@ -531,7 +564,11 @@ function computeTextDimensions(g, gd, legendObj, aTitle) {

var mathjaxGroup = g.select('g[class*=math-group]');
var mathjaxNode = mathjaxGroup.node();
if(!legendObj) legendObj = gd._fullLayout.legend;

var legendId = getId(legendObj);
if(!legendObj) {
legendObj = gd._fullLayout[legendId];
}
var bw = legendObj.borderwidth;
var font;
if(aTitle === MAIN_TITLE) {
Expand All @@ -556,9 +593,12 @@ function computeTextDimensions(g, gd, legendObj, aTitle) {
Drawing.setTranslate(mathjaxGroup, 0, height * 0.25);
}
} else {
var textEl = g.select(aTitle === MAIN_TITLE ?
'.legendtitletext' : '.legendtext'
);
var cls = '.' + legendId + (
aTitle === MAIN_TITLE ? 'title' : ''
) + 'text';

var textEl = g.select(cls);

var textLines = svgTextUtils.lineCount(textEl);
var textNode = textEl.node();

Expand Down Expand Up @@ -619,7 +659,7 @@ function getTitleSize(legendObj) {
}

/*
* Computes in fullLayout.legend:
* Computes in fullLayout[legendId]:
*
* - _height: legend height including items past scrollbox height
* - _maxHeight: maximum legend height before scrollbox is required
Expand All @@ -630,7 +670,10 @@ function getTitleSize(legendObj) {
*/
function computeLegendDimensions(gd, groups, traces, legendObj) {
var fullLayout = gd._fullLayout;
if(!legendObj) legendObj = fullLayout.legend;
var legendId = getId(legendObj);
if(!legendObj) {
legendObj = fullLayout[legendId];
}
var gs = fullLayout._size;

var isVertical = helpers.isVertical(legendObj);
Expand Down Expand Up @@ -818,7 +861,7 @@ function computeLegendDimensions(gd, groups, traces, legendObj) {
var edits = gd._context.edits;
var isEditable = edits.legendText || edits.legendPosition;
traces.each(function(d) {
var traceToggle = d3.select(this).select('.legendtoggle');
var traceToggle = d3.select(this).select('.' + legendId + 'toggle');
var h = d[0].height;
var legendgroup = d[0].trace.legendgroup;
var traceWidth = getTraceWidth(d, legendObj, textGap);
Expand All @@ -833,13 +876,13 @@ function computeLegendDimensions(gd, groups, traces, legendObj) {
});
}

function expandMargin(gd) {
function expandMargin(gd, legendId) {
var fullLayout = gd._fullLayout;
var legendObj = fullLayout.legend;
var legendObj = fullLayout[legendId];
var xanchor = getXanchor(legendObj);
var yanchor = getYanchor(legendObj);

return Plots.autoMargin(gd, 'legend', {
return Plots.autoMargin(gd, legendId, {
x: legendObj.x,
y: legendObj.y,
l: legendObj._width * (FROM_TL[xanchor]),
Expand All @@ -860,3 +903,7 @@ function getYanchor(legendObj) {
Lib.isMiddleAnchor(legendObj) ? 'middle' :
'top';
}

function getId(legendObj) {
return legendObj._id || 'legend';
}