Skip to content

Commit 3f533b2

Browse files
authored
Merge pull request #6535 from plotly/multiple-legends
Draw multiple legends on a graph
2 parents 45a4d98 + d7f278a commit 3f533b2

File tree

13 files changed

+547
-45
lines changed

13 files changed

+547
-45
lines changed

draftlogs/6535_add.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
- Add `legend` references to traces and `legend2`, `legend3`, etc. to layout
2+
to allow positioning multiple legends on a graph [[#6535](https://github.com/plotly/plotly.js/pull/6535)],
3+
this feature was anonymously sponsored: thank you to our sponsor!

src/components/legend/attributes.js

+14
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ var colorAttrs = require('../color/attributes');
55

66

77
module.exports = {
8+
// not really a 'subplot' attribute container,
9+
// but this is the flag we use to denote attributes that
10+
// support yaxis, yaxis2, yaxis3, ... counters
11+
_isSubplotObj: true,
12+
13+
visible: {
14+
valType: 'boolean',
15+
dflt: true,
16+
editType: 'legend',
17+
description: [
18+
'Determines whether or not this legend is visible.'
19+
].join(' ')
20+
},
21+
822
bgcolor: {
923
valType: 'color',
1024
editType: 'legend',

src/components/legend/defaults.js

+36-7
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,22 @@ var attributes = require('./attributes');
99
var basePlotLayoutAttributes = require('../../plots/layout_attributes');
1010
var helpers = require('./helpers');
1111

12-
13-
module.exports = function legendDefaults(layoutIn, layoutOut, fullData) {
14-
var containerIn = layoutIn.legend || {};
15-
var containerOut = Template.newContainer(layoutOut, 'legend');
12+
function groupDefaults(legendId, layoutIn, layoutOut, fullData) {
13+
var containerIn = layoutIn[legendId] || {};
14+
var containerOut = Template.newContainer(layoutOut, legendId);
1615

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

20+
// N.B. unified hover needs to inherit from font, bgcolor & bordercolor even when legend.visible is false
21+
var itemFont = Lib.coerceFont(coerce, 'font', layoutOut.font);
22+
coerce('bgcolor', layoutOut.paper_bgcolor);
23+
coerce('bordercolor');
24+
25+
var visible = coerce('visible');
26+
if(!visible) return;
27+
2128
var trace;
2229
var traceCoerce = function(attr, dflt) {
2330
var traceIn = trace._input;
@@ -91,10 +98,7 @@ module.exports = function legendDefaults(layoutIn, layoutOut, fullData) {
9198

9299
if(showLegend === false) return;
93100

94-
coerce('bgcolor', layoutOut.paper_bgcolor);
95-
coerce('bordercolor');
96101
coerce('borderwidth');
97-
var itemFont = Lib.coerceFont(coerce, 'font', layoutOut.font);
98102

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

148152
Lib.coerceFont(coerce, 'title.font', dfltTitleFont);
149153
}
154+
}
155+
156+
module.exports = function legendDefaults(layoutIn, layoutOut, fullData) {
157+
var i;
158+
var legends = ['legend'];
159+
160+
for(i = 0; i < fullData.length; i++) {
161+
Lib.pushUnique(legends, fullData[i].legend);
162+
}
163+
164+
layoutOut._legends = [];
165+
for(i = 0; i < legends.length; i++) {
166+
var legendId = legends[i];
167+
168+
groupDefaults(legendId, layoutIn, layoutOut, fullData);
169+
170+
if(
171+
layoutOut[legendId] &&
172+
layoutOut[legendId].visible
173+
) {
174+
layoutOut[legendId]._id = legendId;
175+
}
176+
177+
layoutOut._legends.push(legendId);
178+
}
150179
};

src/components/legend/draw.js

+81-34
Original file line numberDiff line numberDiff line change
@@ -24,32 +24,61 @@ var helpers = require('./helpers');
2424

2525
var MAIN_TITLE = 1;
2626

27+
var LEGEND_PATTERN = /^legend[0-9]*$/;
28+
2729
module.exports = function draw(gd, opts) {
28-
if(!opts) opts = gd._fullLayout.legend || {};
29-
return _draw(gd, opts);
30+
if(opts) {
31+
drawOne(gd, opts);
32+
} else {
33+
var fullLayout = gd._fullLayout;
34+
var newLegends = fullLayout._legends;
35+
36+
// remove old legends that won't stay on the graph
37+
var oldLegends = fullLayout._infolayer.selectAll('[class^="legend"]');
38+
39+
oldLegends.each(function() {
40+
var el = d3.select(this);
41+
var classes = el.attr('class');
42+
var cls = classes.split(' ')[0];
43+
if(cls.match(LEGEND_PATTERN) && newLegends.indexOf(cls) === -1) {
44+
el.remove();
45+
}
46+
});
47+
48+
// draw/update new legends
49+
for(var i = 0; i < newLegends.length; i++) {
50+
var legendId = newLegends[i];
51+
var legendObj = gd._fullLayout[legendId];
52+
drawOne(gd, legendObj);
53+
}
54+
}
3055
};
3156

32-
function _draw(gd, legendObj) {
57+
function drawOne(gd, opts) {
58+
var legendObj = opts || {};
59+
3360
var fullLayout = gd._fullLayout;
34-
var clipId = 'legend' + fullLayout._uid;
35-
var layer;
61+
var legendId = getId(legendObj);
62+
63+
var clipId, layer;
3664

3765
var inHover = legendObj._inHover;
3866
if(inHover) {
3967
layer = legendObj.layer;
40-
clipId += '-hover';
68+
clipId = 'hover';
4169
} else {
4270
layer = fullLayout._infolayer;
71+
clipId = legendId;
4372
}
44-
4573
if(!layer) return;
74+
clipId += fullLayout._uid;
4675

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

4978
var legendData;
5079
if(!inHover) {
5180
if(!gd.calcdata) return;
52-
legendData = fullLayout.showlegend && getLegendData(gd.calcdata, legendObj);
81+
legendData = fullLayout.showlegend && getLegendData(gd.calcdata, legendObj, fullLayout._legends.length > 1);
5382
} else {
5483
if(!legendObj.entries) return;
5584
legendData = getLegendData(legendObj.entries, legendObj);
@@ -58,12 +87,12 @@ function _draw(gd, legendObj) {
5887
var hiddenSlices = fullLayout.hiddenlabels || [];
5988

6089
if(!inHover && (!fullLayout.showlegend || !legendData.length)) {
61-
layer.selectAll('.legend').remove();
90+
layer.selectAll('.' + legendId).remove();
6291
fullLayout._topdefs.select('#' + clipId).remove();
63-
return Plots.autoMargin(gd, 'legend');
92+
return Plots.autoMargin(gd, legendId);
6493
}
6594

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

@@ -84,14 +113,14 @@ function _draw(gd, legendObj) {
84113
legendObj._titleWidth = 0;
85114
legendObj._titleHeight = 0;
86115
if(title.text) {
87-
var titleEl = Lib.ensureSingle(scrollBox, 'text', 'legendtitletext');
116+
var titleEl = Lib.ensureSingle(scrollBox, 'text', legendId + 'titletext');
88117
titleEl.attr('text-anchor', 'start')
89118
.call(Drawing.font, title.font)
90119
.text(title.text);
91120

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

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

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

129158
if(!inHover) {
130-
var expMargin = expandMargin(gd);
159+
var expMargin = expandMargin(gd, legendId);
131160

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

147176
if(lx !== lx0) {
148-
Lib.log('Constrain legend.x to make legend fit inside graph');
177+
Lib.log('Constrain ' + legendId + '.x to make legend fit inside graph');
149178
}
150179
if(ly !== ly0) {
151-
Lib.log('Constrain legend.y to make legend fit inside graph');
180+
Lib.log('Constrain ' + legendId + '.y to make legend fit inside graph');
152181
}
153182
}
154183

@@ -294,7 +323,7 @@ function _draw(gd, legendObj) {
294323
}
295324

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

300329
Drawing.setRect(
@@ -330,11 +359,14 @@ function _draw(gd, legendObj) {
330359
},
331360
doneFn: function() {
332361
if(xf !== undefined && yf !== undefined) {
333-
Registry.call('_guiRelayout', gd, {'legend.x': xf, 'legend.y': yf});
362+
var obj = {};
363+
obj[legendId + '.x'] = xf;
364+
obj[legendId + '.y'] = yf;
365+
Registry.call('_guiRelayout', gd, obj);
334366
}
335367
},
336368
clickFn: function(numClicks, e) {
337-
var clickedTrace = layer.selectAll('g.traces').filter(function() {
369+
var clickedTrace = groups.selectAll('g.traces').filter(function() {
338370
var bbox = this.getBoundingClientRect();
339371
return (
340372
e.clientX >= bbox.left && e.clientX <= bbox.right &&
@@ -402,6 +434,7 @@ function clickOrDoubleClick(gd, legend, legendItem, numClicks, evt) {
402434
}
403435

404436
function drawTexts(g, gd, legendObj) {
437+
var legendId = getId(legendObj);
405438
var legendItem = g.data()[0][0];
406439
var trace = legendItem.trace;
407440
var isPieLike = Registry.traceIs(trace, 'pie-like');
@@ -424,7 +457,7 @@ function drawTexts(g, gd, legendObj) {
424457
}
425458
}
426459

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

429462
textEl.attr('text-anchor', 'start')
430463
.call(Drawing.font, font)
@@ -478,12 +511,12 @@ function ensureLength(str, maxLength) {
478511
return str;
479512
}
480513

481-
function setupTraceToggle(g, gd) {
514+
function setupTraceToggle(g, gd, legendId) {
482515
var doubleClickDelay = gd._context.doubleClickDelay;
483516
var newMouseDownTime;
484517
var numClicks = 1;
485518

486-
var traceToggle = Lib.ensureSingle(g, 'rect', 'legendtoggle', function(s) {
519+
var traceToggle = Lib.ensureSingle(g, 'rect', legendId + 'toggle', function(s) {
487520
if(!gd._context.staticPlot) {
488521
s.style('cursor', 'pointer').attr('pointer-events', 'all');
489522
}
@@ -505,7 +538,7 @@ function setupTraceToggle(g, gd) {
505538
});
506539
traceToggle.on('mouseup', function() {
507540
if(gd._dragged || gd._editing) return;
508-
var legend = gd._fullLayout.legend;
541+
var legend = gd._fullLayout[legendId];
509542

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

532565
var mathjaxGroup = g.select('g[class*=math-group]');
533566
var mathjaxNode = mathjaxGroup.node();
534-
if(!legendObj) legendObj = gd._fullLayout.legend;
567+
568+
var legendId = getId(legendObj);
569+
if(!legendObj) {
570+
legendObj = gd._fullLayout[legendId];
571+
}
535572
var bw = legendObj.borderwidth;
536573
var font;
537574
if(aTitle === MAIN_TITLE) {
@@ -556,9 +593,12 @@ function computeTextDimensions(g, gd, legendObj, aTitle) {
556593
Drawing.setTranslate(mathjaxGroup, 0, height * 0.25);
557594
}
558595
} else {
559-
var textEl = g.select(aTitle === MAIN_TITLE ?
560-
'.legendtitletext' : '.legendtext'
561-
);
596+
var cls = '.' + legendId + (
597+
aTitle === MAIN_TITLE ? 'title' : ''
598+
) + 'text';
599+
600+
var textEl = g.select(cls);
601+
562602
var textLines = svgTextUtils.lineCount(textEl);
563603
var textNode = textEl.node();
564604

@@ -619,7 +659,7 @@ function getTitleSize(legendObj) {
619659
}
620660

621661
/*
622-
* Computes in fullLayout.legend:
662+
* Computes in fullLayout[legendId]:
623663
*
624664
* - _height: legend height including items past scrollbox height
625665
* - _maxHeight: maximum legend height before scrollbox is required
@@ -630,7 +670,10 @@ function getTitleSize(legendObj) {
630670
*/
631671
function computeLegendDimensions(gd, groups, traces, legendObj) {
632672
var fullLayout = gd._fullLayout;
633-
if(!legendObj) legendObj = fullLayout.legend;
673+
var legendId = getId(legendObj);
674+
if(!legendObj) {
675+
legendObj = fullLayout[legendId];
676+
}
634677
var gs = fullLayout._size;
635678

636679
var isVertical = helpers.isVertical(legendObj);
@@ -818,7 +861,7 @@ function computeLegendDimensions(gd, groups, traces, legendObj) {
818861
var edits = gd._context.edits;
819862
var isEditable = edits.legendText || edits.legendPosition;
820863
traces.each(function(d) {
821-
var traceToggle = d3.select(this).select('.legendtoggle');
864+
var traceToggle = d3.select(this).select('.' + legendId + 'toggle');
822865
var h = d[0].height;
823866
var legendgroup = d[0].trace.legendgroup;
824867
var traceWidth = getTraceWidth(d, legendObj, textGap);
@@ -833,13 +876,13 @@ function computeLegendDimensions(gd, groups, traces, legendObj) {
833876
});
834877
}
835878

836-
function expandMargin(gd) {
879+
function expandMargin(gd, legendId) {
837880
var fullLayout = gd._fullLayout;
838-
var legendObj = fullLayout.legend;
881+
var legendObj = fullLayout[legendId];
839882
var xanchor = getXanchor(legendObj);
840883
var yanchor = getYanchor(legendObj);
841884

842-
return Plots.autoMargin(gd, 'legend', {
885+
return Plots.autoMargin(gd, legendId, {
843886
x: legendObj.x,
844887
y: legendObj.y,
845888
l: legendObj._width * (FROM_TL[xanchor]),
@@ -860,3 +903,7 @@ function getYanchor(legendObj) {
860903
Lib.isMiddleAnchor(legendObj) ? 'middle' :
861904
'top';
862905
}
906+
907+
function getId(legendObj) {
908+
return legendObj._id || 'legend';
909+
}

0 commit comments

Comments
 (0)