diff --git a/draftlogs/6589_add.md b/draftlogs/6589_add.md new file mode 100644 index 00000000000..726c396b2ef --- /dev/null +++ b/draftlogs/6589_add.md @@ -0,0 +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. \ No newline at end of file diff --git a/src/components/legend/attributes.js b/src/components/legend/attributes.js index 135f1209b69..60eb1f8bc22 100644 --- a/src/components/legend/attributes.js +++ b/src/components/legend/attributes.js @@ -159,13 +159,26 @@ module.exports = { }, x: { valType: 'number', - min: -2, - max: 3, editType: 'legend', 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.' + '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*.' + ].join(' ') + }, + xref: { + valType: 'enumerated', + dflt: 'paper', + values: ['container', 'paper'], + editType: 'layoutstyle', + 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.' ].join(' ') }, xanchor: { @@ -184,14 +197,26 @@ module.exports = { }, y: { valType: 'number', - min: -2, - max: 3, editType: 'legend', description: [ - 'Sets the y position (in normalized coordinates) of the legend.', - 'Defaults to *1* for vertical legends,', + '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.' + '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*.' + ].join(' ') + }, + yref: { + valType: 'enumerated', + dflt: 'paper', + values: ['container', 'paper'], + editType: 'layoutstyle', + 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.' ].join(' ') }, yanchor: { diff --git a/src/components/legend/defaults.js b/src/components/legend/defaults.js index 3906070cb1c..ea14eeaaa55 100644 --- a/src/components/legend/defaults.js +++ b/src/components/legend/defaults.js @@ -101,28 +101,70 @@ function groupDefaults(legendId, layoutIn, layoutOut, fullData) { coerce('borderwidth'); var orientation = coerce('orientation'); + + var yref = coerce('yref'); + var xref = coerce('xref'); + var isHorizontal = orientation === 'h'; + var isPaperY = yref === 'paper'; + var isPaperX = xref === 'paper'; var defaultX, defaultY, defaultYAnchor; + var defaultXAnchor = 'left'; if(isHorizontal) { defaultX = 0; if(Registry.getComponentMethod('rangeslider', 'isVisible')(layoutIn.xaxis)) { - defaultY = 1.1; - defaultYAnchor = 'bottom'; + if(isPaperY) { + defaultY = 1.1; + defaultYAnchor = 'bottom'; + } else { + defaultY = 1; + defaultYAnchor = 'top'; + } } else { // maybe use y=1.1 / yanchor=bottom as above // to avoid https://github.com/plotly/plotly.js/issues/1199 // in v3 - defaultY = -0.1; - defaultYAnchor = 'top'; + if(isPaperY) { + defaultY = -0.1; + defaultYAnchor = 'top'; + } else { + defaultY = 0; + defaultYAnchor = 'bottom'; + } } } else { - defaultX = 1.02; defaultY = 1; defaultYAnchor = 'auto'; + if(isPaperX) { + defaultX = 1.02; + } else { + defaultX = 1; + defaultXAnchor = 'right'; + } } + Lib.coerce(containerIn, containerOut, { + x: { + valType: 'number', + editType: 'legend', + min: isPaperX ? -2 : 0, + max: isPaperX ? 3 : 1, + dflt: defaultX, + } + }, 'x'); + + Lib.coerce(containerIn, containerOut, { + y: { + valType: 'number', + editType: 'legend', + min: isPaperY ? -2 : 0, + max: isPaperY ? 3 : 1, + dflt: defaultY, + } + }, 'y'); + coerce('traceorder', defaultOrder); if(helpers.isGrouped(layoutOut.legend)) coerce('tracegroupgap'); @@ -135,9 +177,7 @@ function groupDefaults(legendId, layoutIn, layoutOut, fullData) { coerce('itemdoubleclick'); coerce('groupclick'); - coerce('x', defaultX); - coerce('xanchor'); - coerce('y', defaultY); + coerce('xanchor', defaultXAnchor); coerce('yanchor', defaultYAnchor); coerce('valign'); Lib.noneOrAll(containerIn, containerOut, ['x', 'y']); diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js index dcb9093b927..ecc2e46e82b 100644 --- a/src/components/legend/draw.js +++ b/src/components/legend/draw.js @@ -154,24 +154,37 @@ function drawOne(gd, opts) { function() { var gs = fullLayout._size; var bw = legendObj.borderwidth; + var isPaperX = legendObj.xref === 'paper'; + var isPaperY = legendObj.yref === 'paper'; if(!inHover) { - var expMargin = expandMargin(gd, legendId); + var lx, ly; + + if(isPaperX) { + lx = gs.l + gs.w * legendObj.x - FROM_TL[getXanchor(legendObj)] * legendObj._width; + } else { + lx = fullLayout.width * legendObj.x - FROM_TL[getXanchor(legendObj)] * legendObj._width; + } + + if(isPaperY) { + ly = gs.t + gs.h * (1 - legendObj.y) - FROM_TL[getYanchor(legendObj)] * legendObj._effHeight; + } else { + ly = fullLayout.height * (1 - legendObj.y) - FROM_TL[getYanchor(legendObj)] * legendObj._effHeight; + } + + var expMargin = expandMargin(gd, legendId, lx, ly); // IF expandMargin return a Promise (which is truthy), // we're under a doAutoMargin redraw, so we don't have to // draw the remaining pieces below if(expMargin) return; - var lx = gs.l + gs.w * legendObj.x - FROM_TL[getXanchor(legendObj)] * legendObj._width; - var ly = gs.t + gs.h * (1 - legendObj.y) - FROM_TL[getYanchor(legendObj)] * legendObj._effHeight; - if(fullLayout.margin.autoexpand) { var lx0 = lx; var ly0 = ly; - lx = Lib.constrain(lx, 0, fullLayout.width - legendObj._width); - ly = Lib.constrain(ly, 0, fullLayout.height - legendObj._effHeight); + lx = isPaperX ? Lib.constrain(lx, 0, fullLayout.width - legendObj._width) : lx0; + ly = isPaperY ? Lib.constrain(ly, 0, fullLayout.height - legendObj._effHeight) : ly0; if(lx !== lx0) { Lib.log('Constrain ' + legendId + '.x to make legend fit inside graph'); @@ -876,20 +889,45 @@ function computeLegendDimensions(gd, groups, traces, legendObj) { }); } -function expandMargin(gd, legendId) { +function expandMargin(gd, legendId, lx, ly) { var fullLayout = gd._fullLayout; var legendObj = fullLayout[legendId]; var xanchor = getXanchor(legendObj); var yanchor = getYanchor(legendObj); - return Plots.autoMargin(gd, legendId, { - x: legendObj.x, - y: legendObj.y, - l: legendObj._width * (FROM_TL[xanchor]), - r: legendObj._width * (FROM_BR[xanchor]), - b: legendObj._effHeight * (FROM_BR[yanchor]), - t: legendObj._effHeight * (FROM_TL[yanchor]) - }); + var isPaperX = legendObj.xref === 'paper'; + var isPaperY = legendObj.yref === 'paper'; + + gd._fullLayout._reservedMargin[legendId] = {}; + var sideY = legendObj.y < 0.5 ? 'b' : 't'; + var sideX = legendObj.x < 0.5 ? 'l' : 'r'; + var possibleReservedMargins = { + r: (fullLayout.width - lx), + l: lx + legendObj._width, + b: (fullLayout.height - ly), + t: ly + legendObj._effHeight + }; + + if(isPaperX && isPaperY) { + return Plots.autoMargin(gd, legendId, { + x: legendObj.x, + y: legendObj.y, + l: legendObj._width * (FROM_TL[xanchor]), + r: legendObj._width * (FROM_BR[xanchor]), + b: legendObj._effHeight * (FROM_BR[yanchor]), + t: legendObj._effHeight * (FROM_TL[yanchor]) + }); + } else if(isPaperX) { + gd._fullLayout._reservedMargin[legendId][sideY] = possibleReservedMargins[sideY]; + } else if(isPaperY) { + gd._fullLayout._reservedMargin[legendId][sideX] = possibleReservedMargins[sideX]; + } else { + if(legendObj.orientation === 'v') { + gd._fullLayout._reservedMargin[legendId][sideX] = possibleReservedMargins[sideX]; + } else { + gd._fullLayout._reservedMargin[legendId][sideY] = possibleReservedMargins[sideY]; + } + } } function getXanchor(legendObj) { diff --git a/test/image/baselines/zz-container-legend.png b/test/image/baselines/zz-container-legend.png new file mode 100644 index 00000000000..2d73c25edc8 Binary files /dev/null and b/test/image/baselines/zz-container-legend.png differ diff --git a/test/image/mocks/zz-container-legend.json b/test/image/mocks/zz-container-legend.json new file mode 100644 index 00000000000..08bd25d8694 --- /dev/null +++ b/test/image/mocks/zz-container-legend.json @@ -0,0 +1,75 @@ +{ + "data": [ + { + "y": [0] + }, + { + "y": [1] + }, + { + "y": [2] + }, + { + "y": [3], + "legend": "legend2" + }, + { + "y": [4], + "legend": "legend3" + }, + { + "y": [5], + "legend": "legend3" + } + ], + "layout": { + "margin": {"t": 0, "b": 0, "r": 0, "l": 0}, + "title": { + "text": "Multiple legends | Legends 1 & 2 with container ref", + "automargin": true, + "yref": "container" + }, + "width": 500, + "height": 500, + "yaxis": { + "autorange": "reversed", + "title": {"text": "Long axis title with standoff", "font": {"size": 24}, "standoff": 25}, + "side": "right", + "automargin": true + }, + "xaxis": {"title": {"text": "Xaxis title"}, "automargin": true}, + "legend": { + "bgcolor": "lightgray", + "xref": "container", + "title": { + "text": "Legend" + } + }, + "legend2": { + "x": 0, + "y": 0.5, + "xanchor": "right", + "yanchor": "top", + "bgcolor": "lightblue", + "title": { + "text": "Legend 2" + } + }, + "legend3": { + "y": 0, + "x": 0.5, + "orientation": "h", + "yref": "container", + "xref": "container", + "xanchor": "center", + "bgcolor": "yellow", + "title": { + "text": "Legend 3" + } + }, + "hovermode": "x unified" + }, + "config": { + "editable": true + } +} diff --git a/test/plot-schema.json b/test/plot-schema.json index 38cb87d45b3..062a5bc142a 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -2980,10 +2980,8 @@ "valType": "boolean" }, "x": { - "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.", + "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*.", "editType": "legend", - "max": 3, - "min": -2, "valType": "number" }, "xanchor": { @@ -2998,11 +2996,19 @@ "right" ] }, + "xref": { + "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.", + "dflt": "paper", + "editType": "layoutstyle", + "valType": "enumerated", + "values": [ + "container", + "paper" + ] + }, "y": { - "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.", + "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*.", "editType": "legend", - "max": 3, - "min": -2, "valType": "number" }, "yanchor": { @@ -3015,6 +3021,16 @@ "middle", "bottom" ] + }, + "yref": { + "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.", + "dflt": "paper", + "editType": "layoutstyle", + "valType": "enumerated", + "values": [ + "container", + "paper" + ] } }, "mapbox": {