Skip to content

Commit cc88162

Browse files
authored
Merge pull request #6589 from plotly/legend-positioning
Add `xref` and `yref` to legends
2 parents 39c2f06 + 5a9e953 commit cc88162

File tree

7 files changed

+234
-39
lines changed

7 files changed

+234
-39
lines changed

Diff for: draftlogs/6589_add.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Add `legend.xref` and `legend.yref` to enable container-referenced positioning for plot legends [[#6589](https://github.com/plotly/plotly.js/pull/6589)], with thanks to [Gamma Technologies](https://www.gtisoft.com/) for sponsoring the related development.

Diff for: src/components/legend/attributes.js

+35-10
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,26 @@ module.exports = {
159159
},
160160
x: {
161161
valType: 'number',
162-
min: -2,
163-
max: 3,
164162
editType: 'legend',
165163
description: [
166-
'Sets the x position (in normalized coordinates) of the legend.',
167-
'Defaults to *1.02* for vertical legends and',
168-
'defaults to *0* for horizontal legends.'
164+
'Sets the x position with respect to `xref` (in normalized coordinates) of the legend.',
165+
'When `xref` is *paper*, defaults to *1.02* for vertical legends and',
166+
'defaults to *0* for horizontal legends.',
167+
'When `xref` is *container*, defaults to *1* for vertical legends and',
168+
'defaults to *0* for horizontal legends.',
169+
'Must be between *0* and *1* if `xref` is *container*.',
170+
'and between *-2* and *3* if `xref` is *paper*.'
171+
].join(' ')
172+
},
173+
xref: {
174+
valType: 'enumerated',
175+
dflt: 'paper',
176+
values: ['container', 'paper'],
177+
editType: 'layoutstyle',
178+
description: [
179+
'Sets the container `x` refers to.',
180+
'*container* spans the entire `width` of the plot.',
181+
'*paper* refers to the width of the plotting area only.'
169182
].join(' ')
170183
},
171184
xanchor: {
@@ -184,14 +197,26 @@ module.exports = {
184197
},
185198
y: {
186199
valType: 'number',
187-
min: -2,
188-
max: 3,
189200
editType: 'legend',
190201
description: [
191-
'Sets the y position (in normalized coordinates) of the legend.',
192-
'Defaults to *1* for vertical legends,',
202+
'Sets the y position with respect to `yref` (in normalized coordinates) of the legend.',
203+
'When `yref` is *paper*, defaults to *1* for vertical legends,',
193204
'defaults to *-0.1* for horizontal legends on graphs w/o range sliders and',
194-
'defaults to *1.1* for horizontal legends on graph with one or multiple range sliders.'
205+
'defaults to *1.1* for horizontal legends on graph with one or multiple range sliders.',
206+
'When `yref` is *container*, defaults to *1*.',
207+
'Must be between *0* and *1* if `yref` is *container*',
208+
'and between *-2* and *3* if `yref` is *paper*.'
209+
].join(' ')
210+
},
211+
yref: {
212+
valType: 'enumerated',
213+
dflt: 'paper',
214+
values: ['container', 'paper'],
215+
editType: 'layoutstyle',
216+
description: [
217+
'Sets the container `y` refers to.',
218+
'*container* spans the entire `height` of the plot.',
219+
'*paper* refers to the height of the plotting area only.'
195220
].join(' ')
196221
},
197222
yanchor: {

Diff for: src/components/legend/defaults.js

+48-8
Original file line numberDiff line numberDiff line change
@@ -101,28 +101,70 @@ function groupDefaults(legendId, layoutIn, layoutOut, fullData) {
101101
coerce('borderwidth');
102102

103103
var orientation = coerce('orientation');
104+
105+
var yref = coerce('yref');
106+
var xref = coerce('xref');
107+
104108
var isHorizontal = orientation === 'h';
109+
var isPaperY = yref === 'paper';
110+
var isPaperX = xref === 'paper';
105111
var defaultX, defaultY, defaultYAnchor;
112+
var defaultXAnchor = 'left';
106113

107114
if(isHorizontal) {
108115
defaultX = 0;
109116

110117
if(Registry.getComponentMethod('rangeslider', 'isVisible')(layoutIn.xaxis)) {
111-
defaultY = 1.1;
112-
defaultYAnchor = 'bottom';
118+
if(isPaperY) {
119+
defaultY = 1.1;
120+
defaultYAnchor = 'bottom';
121+
} else {
122+
defaultY = 1;
123+
defaultYAnchor = 'top';
124+
}
113125
} else {
114126
// maybe use y=1.1 / yanchor=bottom as above
115127
// to avoid https://github.com/plotly/plotly.js/issues/1199
116128
// in v3
117-
defaultY = -0.1;
118-
defaultYAnchor = 'top';
129+
if(isPaperY) {
130+
defaultY = -0.1;
131+
defaultYAnchor = 'top';
132+
} else {
133+
defaultY = 0;
134+
defaultYAnchor = 'bottom';
135+
}
119136
}
120137
} else {
121-
defaultX = 1.02;
122138
defaultY = 1;
123139
defaultYAnchor = 'auto';
140+
if(isPaperX) {
141+
defaultX = 1.02;
142+
} else {
143+
defaultX = 1;
144+
defaultXAnchor = 'right';
145+
}
124146
}
125147

148+
Lib.coerce(containerIn, containerOut, {
149+
x: {
150+
valType: 'number',
151+
editType: 'legend',
152+
min: isPaperX ? -2 : 0,
153+
max: isPaperX ? 3 : 1,
154+
dflt: defaultX,
155+
}
156+
}, 'x');
157+
158+
Lib.coerce(containerIn, containerOut, {
159+
y: {
160+
valType: 'number',
161+
editType: 'legend',
162+
min: isPaperY ? -2 : 0,
163+
max: isPaperY ? 3 : 1,
164+
dflt: defaultY,
165+
}
166+
}, 'y');
167+
126168
coerce('traceorder', defaultOrder);
127169
if(helpers.isGrouped(layoutOut.legend)) coerce('tracegroupgap');
128170

@@ -135,9 +177,7 @@ function groupDefaults(legendId, layoutIn, layoutOut, fullData) {
135177
coerce('itemdoubleclick');
136178
coerce('groupclick');
137179

138-
coerce('x', defaultX);
139-
coerce('xanchor');
140-
coerce('y', defaultY);
180+
coerce('xanchor', defaultXAnchor);
141181
coerce('yanchor', defaultYAnchor);
142182
coerce('valign');
143183
Lib.noneOrAll(containerIn, containerOut, ['x', 'y']);

Diff for: src/components/legend/draw.js

+53-15
Original file line numberDiff line numberDiff line change
@@ -154,24 +154,37 @@ function drawOne(gd, opts) {
154154
function() {
155155
var gs = fullLayout._size;
156156
var bw = legendObj.borderwidth;
157+
var isPaperX = legendObj.xref === 'paper';
158+
var isPaperY = legendObj.yref === 'paper';
157159

158160
if(!inHover) {
159-
var expMargin = expandMargin(gd, legendId);
161+
var lx, ly;
162+
163+
if(isPaperX) {
164+
lx = gs.l + gs.w * legendObj.x - FROM_TL[getXanchor(legendObj)] * legendObj._width;
165+
} else {
166+
lx = fullLayout.width * legendObj.x - FROM_TL[getXanchor(legendObj)] * legendObj._width;
167+
}
168+
169+
if(isPaperY) {
170+
ly = gs.t + gs.h * (1 - legendObj.y) - FROM_TL[getYanchor(legendObj)] * legendObj._effHeight;
171+
} else {
172+
ly = fullLayout.height * (1 - legendObj.y) - FROM_TL[getYanchor(legendObj)] * legendObj._effHeight;
173+
}
174+
175+
var expMargin = expandMargin(gd, legendId, lx, ly);
160176

161177
// IF expandMargin return a Promise (which is truthy),
162178
// we're under a doAutoMargin redraw, so we don't have to
163179
// draw the remaining pieces below
164180
if(expMargin) return;
165181

166-
var lx = gs.l + gs.w * legendObj.x - FROM_TL[getXanchor(legendObj)] * legendObj._width;
167-
var ly = gs.t + gs.h * (1 - legendObj.y) - FROM_TL[getYanchor(legendObj)] * legendObj._effHeight;
168-
169182
if(fullLayout.margin.autoexpand) {
170183
var lx0 = lx;
171184
var ly0 = ly;
172185

173-
lx = Lib.constrain(lx, 0, fullLayout.width - legendObj._width);
174-
ly = Lib.constrain(ly, 0, fullLayout.height - legendObj._effHeight);
186+
lx = isPaperX ? Lib.constrain(lx, 0, fullLayout.width - legendObj._width) : lx0;
187+
ly = isPaperY ? Lib.constrain(ly, 0, fullLayout.height - legendObj._effHeight) : ly0;
175188

176189
if(lx !== lx0) {
177190
Lib.log('Constrain ' + legendId + '.x to make legend fit inside graph');
@@ -876,20 +889,45 @@ function computeLegendDimensions(gd, groups, traces, legendObj) {
876889
});
877890
}
878891

879-
function expandMargin(gd, legendId) {
892+
function expandMargin(gd, legendId, lx, ly) {
880893
var fullLayout = gd._fullLayout;
881894
var legendObj = fullLayout[legendId];
882895
var xanchor = getXanchor(legendObj);
883896
var yanchor = getYanchor(legendObj);
884897

885-
return Plots.autoMargin(gd, legendId, {
886-
x: legendObj.x,
887-
y: legendObj.y,
888-
l: legendObj._width * (FROM_TL[xanchor]),
889-
r: legendObj._width * (FROM_BR[xanchor]),
890-
b: legendObj._effHeight * (FROM_BR[yanchor]),
891-
t: legendObj._effHeight * (FROM_TL[yanchor])
892-
});
898+
var isPaperX = legendObj.xref === 'paper';
899+
var isPaperY = legendObj.yref === 'paper';
900+
901+
gd._fullLayout._reservedMargin[legendId] = {};
902+
var sideY = legendObj.y < 0.5 ? 'b' : 't';
903+
var sideX = legendObj.x < 0.5 ? 'l' : 'r';
904+
var possibleReservedMargins = {
905+
r: (fullLayout.width - lx),
906+
l: lx + legendObj._width,
907+
b: (fullLayout.height - ly),
908+
t: ly + legendObj._effHeight
909+
};
910+
911+
if(isPaperX && isPaperY) {
912+
return Plots.autoMargin(gd, legendId, {
913+
x: legendObj.x,
914+
y: legendObj.y,
915+
l: legendObj._width * (FROM_TL[xanchor]),
916+
r: legendObj._width * (FROM_BR[xanchor]),
917+
b: legendObj._effHeight * (FROM_BR[yanchor]),
918+
t: legendObj._effHeight * (FROM_TL[yanchor])
919+
});
920+
} else if(isPaperX) {
921+
gd._fullLayout._reservedMargin[legendId][sideY] = possibleReservedMargins[sideY];
922+
} else if(isPaperY) {
923+
gd._fullLayout._reservedMargin[legendId][sideX] = possibleReservedMargins[sideX];
924+
} else {
925+
if(legendObj.orientation === 'v') {
926+
gd._fullLayout._reservedMargin[legendId][sideX] = possibleReservedMargins[sideX];
927+
} else {
928+
gd._fullLayout._reservedMargin[legendId][sideY] = possibleReservedMargins[sideY];
929+
}
930+
}
893931
}
894932

895933
function getXanchor(legendObj) {

Diff for: test/image/baselines/zz-container-legend.png

33.8 KB
Loading

Diff for: test/image/mocks/zz-container-legend.json

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
{
2+
"data": [
3+
{
4+
"y": [0]
5+
},
6+
{
7+
"y": [1]
8+
},
9+
{
10+
"y": [2]
11+
},
12+
{
13+
"y": [3],
14+
"legend": "legend2"
15+
},
16+
{
17+
"y": [4],
18+
"legend": "legend3"
19+
},
20+
{
21+
"y": [5],
22+
"legend": "legend3"
23+
}
24+
],
25+
"layout": {
26+
"margin": {"t": 0, "b": 0, "r": 0, "l": 0},
27+
"title": {
28+
"text": "Multiple legends | Legends 1 & 2 with container ref",
29+
"automargin": true,
30+
"yref": "container"
31+
},
32+
"width": 500,
33+
"height": 500,
34+
"yaxis": {
35+
"autorange": "reversed",
36+
"title": {"text": "Long axis title with standoff", "font": {"size": 24}, "standoff": 25},
37+
"side": "right",
38+
"automargin": true
39+
},
40+
"xaxis": {"title": {"text": "Xaxis title"}, "automargin": true},
41+
"legend": {
42+
"bgcolor": "lightgray",
43+
"xref": "container",
44+
"title": {
45+
"text": "Legend"
46+
}
47+
},
48+
"legend2": {
49+
"x": 0,
50+
"y": 0.5,
51+
"xanchor": "right",
52+
"yanchor": "top",
53+
"bgcolor": "lightblue",
54+
"title": {
55+
"text": "Legend 2"
56+
}
57+
},
58+
"legend3": {
59+
"y": 0,
60+
"x": 0.5,
61+
"orientation": "h",
62+
"yref": "container",
63+
"xref": "container",
64+
"xanchor": "center",
65+
"bgcolor": "yellow",
66+
"title": {
67+
"text": "Legend 3"
68+
}
69+
},
70+
"hovermode": "x unified"
71+
},
72+
"config": {
73+
"editable": true
74+
}
75+
}

Diff for: test/plot-schema.json

+22-6
Original file line numberDiff line numberDiff line change
@@ -2980,10 +2980,8 @@
29802980
"valType": "boolean"
29812981
},
29822982
"x": {
2983-
"description": "Sets the x position (in normalized coordinates) of the legend. Defaults to *1.02* for vertical legends and defaults to *0* for horizontal legends.",
2983+
"description": "Sets the x position with respect to `xref` (in normalized coordinates) of the legend. When `xref` is *paper*, defaults to *1.02* for vertical legends and defaults to *0* for horizontal legends. When `xref` is *container*, defaults to *1* for vertical legends and defaults to *0* for horizontal legends. Must be between *0* and *1* if `xref` is *container*. and between *-2* and *3* if `xref` is *paper*.",
29842984
"editType": "legend",
2985-
"max": 3,
2986-
"min": -2,
29872985
"valType": "number"
29882986
},
29892987
"xanchor": {
@@ -2998,11 +2996,19 @@
29982996
"right"
29992997
]
30002998
},
2999+
"xref": {
3000+
"description": "Sets the container `x` refers to. *container* spans the entire `width` of the plot. *paper* refers to the width of the plotting area only.",
3001+
"dflt": "paper",
3002+
"editType": "layoutstyle",
3003+
"valType": "enumerated",
3004+
"values": [
3005+
"container",
3006+
"paper"
3007+
]
3008+
},
30013009
"y": {
3002-
"description": "Sets the y position (in normalized coordinates) of the legend. Defaults to *1* for vertical legends, defaults to *-0.1* for horizontal legends on graphs w/o range sliders and defaults to *1.1* for horizontal legends on graph with one or multiple range sliders.",
3010+
"description": "Sets the y position with respect to `yref` (in normalized coordinates) of the legend. When `yref` is *paper*, defaults to *1* for vertical legends, defaults to *-0.1* for horizontal legends on graphs w/o range sliders and defaults to *1.1* for horizontal legends on graph with one or multiple range sliders. When `yref` is *container*, defaults to *1*. Must be between *0* and *1* if `yref` is *container* and between *-2* and *3* if `yref` is *paper*.",
30033011
"editType": "legend",
3004-
"max": 3,
3005-
"min": -2,
30063012
"valType": "number"
30073013
},
30083014
"yanchor": {
@@ -3015,6 +3021,16 @@
30153021
"middle",
30163022
"bottom"
30173023
]
3024+
},
3025+
"yref": {
3026+
"description": "Sets the container `y` refers to. *container* spans the entire `height` of the plot. *paper* refers to the height of the plotting area only.",
3027+
"dflt": "paper",
3028+
"editType": "layoutstyle",
3029+
"valType": "enumerated",
3030+
"values": [
3031+
"container",
3032+
"paper"
3033+
]
30183034
}
30193035
},
30203036
"mapbox": {

0 commit comments

Comments
 (0)