Skip to content

Multiple legend positioning and sizing fixes - #4160

Merged
etpinard merged 20 commits into
masterfrom
legend-horizontal-computed-dims-fix
Sep 23, 2019
Merged

Multiple legend positioning and sizing fixes#4160
etpinard merged 20 commits into
masterfrom
legend-horizontal-computed-dims-fix

Conversation

@etpinard

Copy link
Copy Markdown
Contributor

This PR essentially supersedes #3024, fixes#4132 and will resolve#771

In brief,

Reviewers may be interested in reviewing

vargs=fullLayout._size;
varbw=opts.borderwidth;
varlx=gs.l+gs.w*opts.x-FROM_TL[getXanchor(opts)]*opts._width;
varly=gs.t+gs.h*(1-opts.y)-FROM_TL[getYanchor(opts)]*opts._effHeight;
if(fullLayout.margin.autoexpand){
varlx0=lx;
varly0=ly;
lx=Lib.constrain(lx,0,fullLayout.width-opts._width);
ly=Lib.constrain(ly,0,fullLayout.height-opts._effHeight);
if(lx!==lx0){
Lib.log('Constrain legend.x to make legend fit inside graph');
}
if(ly!==ly0){
Lib.log('Constrain legend.y to make legend fit inside graph');
}
}

/*
* Computes in fullLayout.legend:
*
* - _height: legend height including items past scrollbox height
* - _maxHeight: maximum legend height before scrollbox is required
* - _effHeight: legend height w/ or w/o scrollbox
*
* - _width: legend width
* - _maxWidth (for orientation:h only): maximum width before starting new row
*/
functioncomputeLegendDimensions(gd,groups,traces){
varfullLayout=gd._fullLayout;
varopts=fullLayout.legend;
vargs=fullLayout._size;
varisVertical=helpers.isVertical(opts);
varisGrouped=helpers.isGrouped(opts);
varbw=opts.borderwidth;
varbw2=2*bw;
vartextGap=constants.textGap;
varitemGap=constants.itemGap;
varendPad=2*(bw+itemGap);
varyanchor=getYanchor(opts);
varisBelowPlotArea=opts.y<0||(opts.y===0&&yanchor==='top');
varisAbovePlotArea=opts.y>1||(opts.y===1&&yanchor==='bottom');
// - if below/above plot area, give it the maximum potential margin-push value
// - otherwise, extend the height of the plot area
opts._maxHeight=Math.max(
(isBelowPlotArea||isAbovePlotArea) ? fullLayout.height/2 : gs.h,
30
);
vartoggleRectWidth=0;
opts._width=0;
opts._height=0;
if(isVertical){
traces.each(function(d){
varh=d[0].height;
Drawing.setTranslate(this,bw,itemGap+bw+opts._height+h/2);
opts._height+=h;
opts._width=Math.max(opts._width,d[0].width);
});
toggleRectWidth=textGap+opts._width;
opts._width+=itemGap+textGap+bw2;
opts._height+=endPad;
if(isGrouped){
groups.each(function(d,i){
Drawing.setTranslate(this,0,i*opts.tracegroupgap);
});
opts._height+=(opts._lgroupsLength-1)*opts.tracegroupgap;
}
}else{
varxanchor=getXanchor(opts);
varisLeftOfPlotArea=opts.x<0||(opts.x===0&&xanchor==='right');
varisRightOfPlotArea=opts.x>1||(opts.x===1&&xanchor==='left');
varisBeyondPlotAreaX=isAbovePlotArea||isBelowPlotArea;
varhw=fullLayout.width/2;
// - if placed within x-margins, extend the width of the plot area
// - else if below/above plot area and anchored in the margin, extend to opposite margin,
// - otherwise give it the maximum potential margin-push value
opts._maxWidth=Math.max(
isLeftOfPlotArea ? ((isBeyondPlotAreaX&&xanchor==='left') ? gs.l+gs.w : hw) :
isRightOfPlotArea ? ((isBeyondPlotAreaX&&yanchor==='right') ? gs.r+gs.w : hw) :
gs.w,
2*textGap);
varmaxItemWidth=0;
varcombinedItemWidth=0;
traces.each(function(d){
varw=d[0].width+textGap;
maxItemWidth=Math.max(maxItemWidth,w);
combinedItemWidth+=w;
});
toggleRectWidth=null;
varmaxRowWidth=0;
if(isGrouped){
varmaxGroupHeightInRow=0;
vargroupOffsetX=0;
vargroupOffsetY=0;
groups.each(function(){
varmaxWidthInGroup=0;
varoffsetY=0;
d3.select(this).selectAll('g.traces').each(function(d){
varh=d[0].height;
Drawing.setTranslate(this,0,itemGap+bw+h/2+offsetY);
offsetY+=h;
maxWidthInGroup=Math.max(maxWidthInGroup,textGap+d[0].width);
});
maxGroupHeightInRow=Math.max(maxGroupHeightInRow,offsetY);
varnext=maxWidthInGroup+itemGap;
if((next+bw+groupOffsetX)>opts._maxWidth){
maxRowWidth=Math.max(maxRowWidth,groupOffsetX);
groupOffsetX=0;
groupOffsetY+=maxGroupHeightInRow+opts.tracegroupgap;
maxGroupHeightInRow=offsetY;
}
Drawing.setTranslate(this,groupOffsetX,groupOffsetY);
groupOffsetX+=next;
});
opts._width=Math.max(maxRowWidth,groupOffsetX)+bw;
opts._height=groupOffsetY+maxGroupHeightInRow+endPad;
}else{
varnTraces=traces.size();
varoneRowLegend=(combinedItemWidth+bw2+(nTraces-1)*itemGap)<opts._maxWidth;
varmaxItemHeightInRow=0;
varoffsetX=0;
varoffsetY=0;
varrowWidth=0;
traces.each(function(d){
varh=d[0].height;
varw=textGap+d[0].width;
varnext=(oneRowLegend ? w : maxItemWidth)+itemGap;
if((next+bw+offsetX)>opts._maxWidth){
maxRowWidth=Math.max(maxRowWidth,rowWidth);
offsetX=0;
offsetY+=maxItemHeightInRow;
opts._height+=maxItemHeightInRow;
maxItemHeightInRow=0;
}
Drawing.setTranslate(this,bw+offsetX,itemGap+bw+h/2+offsetY);
rowWidth=offsetX+w+itemGap;
offsetX+=next;
maxItemHeightInRow=Math.max(maxItemHeightInRow,h);
});
if(oneRowLegend){
opts._width=offsetX+bw2;
opts._height=maxItemHeightInRow+endPad;
}else{
opts._width=Math.max(maxRowWidth,rowWidth)+bw2;
opts._height+=maxItemHeightInRow+endPad;
}
}
}
opts._width=Math.ceil(opts._width);
opts._height=Math.ceil(opts._height);
opts._effHeight=Math.min(opts._height,opts._maxHeight);
varedits=gd._context.edits;
varisEditable=edits.legendText||edits.legendPosition;
traces.each(function(d){
vartraceToggle=d3.select(this).select('.legendtoggle');
varh=d[0].height;
varw=isEditable ? textGap : (toggleRectWidth||(textGap+d[0].width));
if(!isVertical)w+=itemGap/2;
Drawing.setRect(traceToggle,0,-h/2,w,h);
});
}

in legend/draw.js with their corresponding blocks on the current master:

// Position and size the legend
varlxMin=0;
varlxMax=fullLayout.width;
varlyMin=0;
varlyMax=fullLayout.height;
computeLegendDimensions(gd,groups,traces);
if(opts._height>lyMax){
// If the legend doesn't fit in the plot area,
// do not expand the vertical margins.
expandHorizontalMargin(gd);
}else{
expandMargin(gd);
}
// Scroll section must be executed after repositionLegend.
// It requires the legend width, height, x and y to position the scrollbox
// and these values are mutated in repositionLegend.
vargs=fullLayout._size;
varlx=gs.l+gs.w*opts.x;
varly=gs.t+gs.h*(1-opts.y);
if(Lib.isRightAnchor(opts)){
lx-=opts._width;
}elseif(Lib.isCenterAnchor(opts)){
lx-=opts._width/2;
}
if(Lib.isBottomAnchor(opts)){
ly-=opts._height;
}elseif(Lib.isMiddleAnchor(opts)){
ly-=opts._height/2;
}
// Make sure the legend left and right sides are visible
varlegendWidth=opts._width;
varlegendWidthMax=gs.w;
if(legendWidth>legendWidthMax){
lx=gs.l;
legendWidth=legendWidthMax;
}else{
if(lx+legendWidth>lxMax)lx=lxMax-legendWidth;
if(lx<lxMin)lx=lxMin;
legendWidth=Math.min(lxMax-lx,opts._width);
}
// Make sure the legend top and bottom are visible
// (legends with a scroll bar are not allowed to stretch beyond the extended
// margins)
varlegendHeight=opts._height;
varlegendHeightMax=gs.h;
if(legendHeight>legendHeightMax){
ly=gs.t;
legendHeight=legendHeightMax;
}else{
if(ly+legendHeight>lyMax)ly=lyMax-legendHeight;
if(ly<lyMin)ly=lyMin;
legendHeight=Math.min(lyMax-ly,opts._height);
}

functioncomputeLegendDimensions(gd,groups,traces){
varfullLayout=gd._fullLayout;
varopts=fullLayout.legend;
varborderwidth=opts.borderwidth;
varisGrouped=helpers.isGrouped(opts);
varextraWidth=0;
vartraceGap=5;
opts._width=0;
opts._height=0;
if(helpers.isVertical(opts)){
if(isGrouped){
groups.each(function(d,i){
Drawing.setTranslate(this,0,i*opts.tracegroupgap);
});
}
traces.each(function(d){
varlegendItem=d[0];
vartextHeight=legendItem.height;
vartextWidth=legendItem.width;
Drawing.setTranslate(this,
borderwidth,
(5+borderwidth+opts._height+textHeight/2));
opts._height+=textHeight;
opts._width=Math.max(opts._width,textWidth);
});
opts._width+=45+borderwidth*2;
opts._height+=10+borderwidth*2;
if(isGrouped){
opts._height+=(opts._lgroupsLength-1)*opts.tracegroupgap;
}
extraWidth=40;
}elseif(isGrouped){
varmaxHeight=0;
varmaxWidth=0;
vargroupData=groups.data();
varmaxItems=0;
vari;
for(i=0;i<groupData.length;i++){
vargroup=groupData[i];
vargroupWidths=group.map(function(legendItemArray){
returnlegendItemArray[0].width;
});
vargroupWidth=Lib.aggNums(Math.max,null,groupWidths);
vargroupHeight=group.reduce(function(a,b){
returna+b[0].height;
},0);
maxWidth=Math.max(maxWidth,groupWidth);
maxHeight=Math.max(maxHeight,groupHeight);
maxItems=Math.max(maxItems,group.length);
}
maxWidth+=traceGap;
maxWidth+=40;
vargroupXOffsets=[opts._width];
vargroupYOffsets=[];
varrowNum=0;
for(i=0;i<groupData.length;i++){
if(fullLayout._size.w<(borderwidth+opts._width+traceGap+maxWidth)){
groupXOffsets[groupXOffsets.length-1]=groupXOffsets[0];
opts._width=maxWidth;
rowNum++;
}else{
opts._width+=maxWidth+borderwidth;
}
varrowYOffset=(rowNum*maxHeight);
rowYOffset+=rowNum>0 ? opts.tracegroupgap : 0;
groupYOffsets.push(rowYOffset);
groupXOffsets.push(opts._width);
}
groups.each(function(d,i){
Drawing.setTranslate(this,groupXOffsets[i],groupYOffsets[i]);
});
groups.each(function(){
vargroup=d3.select(this);
vargroupTraces=group.selectAll('g.traces');
vargroupHeight=0;
groupTraces.each(function(d){
varlegendItem=d[0];
vartextHeight=legendItem.height;
Drawing.setTranslate(this,
0,
(5+borderwidth+groupHeight+textHeight/2));
groupHeight+=textHeight;
});
});
varmaxYLegend=groupYOffsets[groupYOffsets.length-1]+maxHeight;
opts._height=10+(borderwidth*2)+maxYLegend;
varmaxOffset=Math.max.apply(null,groupXOffsets);
opts._width=maxOffset+maxWidth+40;
opts._width+=borderwidth*2;
}else{
varrowHeight=0;
varmaxTraceHeight=0;
varmaxTraceWidth=0;
varoffsetX=0;
varfullTracesWidth=0;
// calculate largest width for traces and use for width of all legend items
traces.each(function(d){
maxTraceWidth=Math.max(40+d[0].width,maxTraceWidth);
fullTracesWidth+=40+d[0].width+traceGap;
});
// check if legend fits in one row
varoneRowLegend=fullLayout._size.w>borderwidth+fullTracesWidth-traceGap;
traces.each(function(d){
varlegendItem=d[0];
vartraceWidth=oneRowLegend ? 40+d[0].width : maxTraceWidth;
if((borderwidth+offsetX+traceGap+traceWidth)>fullLayout._size.w){
offsetX=0;
rowHeight+=maxTraceHeight;
opts._height+=maxTraceHeight;
// reset for next row
maxTraceHeight=0;
}
Drawing.setTranslate(this,
(borderwidth+offsetX),
(5+borderwidth+legendItem.height/2)+rowHeight);
opts._width+=traceGap+traceWidth;
// keep track of tallest trace in group
offsetX+=traceGap+traceWidth;
maxTraceHeight=Math.max(legendItem.height,maxTraceHeight);
});
if(oneRowLegend){
opts._height=maxTraceHeight;
}else{
opts._height+=maxTraceHeight;
}
opts._width+=borderwidth*2;
opts._height+=10+borderwidth*2;
}
// make sure we're only getting full pixels
opts._width=Math.ceil(opts._width);
opts._height=Math.ceil(opts._height);
varisEditable=(
gd._context.edits.legendText||
gd._context.edits.legendPosition
);
traces.each(function(d){
varlegendItem=d[0];
varbg=d3.select(this).select('.legendtoggle');
Drawing.setRect(bg,
0,
-legendItem.height/2,
(isEditable ? 0 : opts._width)+extraWidth,
legendItem.height
);
});
}

cc @plotly/plotly_js

... src/lib/anchor_utils.js has the same logic
- do not list `dflt` in schema as their value depends on
the legend orientation (and rangeslider ;)
- improve attr descriptions
- add comment for potential v2 improvement
... during calcdata loop, instead of in Legend.draw
this allows us to 🔪 one loop over the data
that plus some linting.
- set scrollBar enter attrs in constants file
- rename textOffsetX -> textGap, add itemGap constants
... and use `bw` for borderwidth and `h` for d[0].height
making formula cleaner in computeLegendDimensions
... which doesn't appear to do anything,
and mv computeLegendDimension to separate step
- use fullLayout.(width|height) instead of ly(Min|Max)
- use bw instead of opts.borderwidth
- 🔪 double-empty-line
- 🔪 some useless comments
... the current width didn't lead to any bugs, but
it was too large in most cases.
- make horiz. grouped / non-grouped logic more similar
- DRY up x/y anchor logic
- mocks with negative x with orientation h
- mock with small viewports
- mock with legend beyond plotarea + scrollbox
- mock with long item leading to x margin push
- see #771
- introduce measures _maxHeight, _maxWidth and _effHeight
to track margin pushes, scrollbox and horizontal row wrapping
- simplify (and fix) legend (x,y) constraint into graph width/height
- introduce some l,r,b,y "max" margin-push logic
- paves way to expose legend `maxheight` and `maxwidth`
- update baselines!
+ from previous "new legend mocks" commit
+ and `horizontal_wrap-alll-lines` which now spans the graph's width
- when one sets autoexpand to false, fullLayout.margin are
honoured no matter how big the legend gets. In this case, (I'd argue)
the legend should NOT move from its provided (or default) (x,y)
position to make it fit on the graph.
- if some users want the legend to auto-expand the margin while keeping
the provided/default (x,y), we could eventually add a `legend.fitinside`
boolean attribute
- N.B. need to remove legend from splom test to get
Lib.log counter right.
- which gives better margin pushes,
fixes legendgroup_horizontal_wrapping mock
- use per-item width for toggle-rect width in ALL horizontal legends,
we could eventually compute "per-column" width, if
someone finds it useful
@etpinardetpinard added this to the v1.50.0 milestone Aug 30, 2019
@nicolaskruchten

Copy link
Copy Markdown
Contributor

Is this on the way to resolving #1199 or unrelated?

@etpinard

Copy link
Copy Markdown
ContributorAuthor

@antoinerg would you mind reviewing this one?

@antoinerg
antoinerg self-requested a review September 3, 2019 16:06
@antoinergantoinerg self-assigned this Sep 3, 2019
@antoinerg
antoinerg removed their request for review September 3, 2019 16:06
@antoinerg

Copy link
Copy Markdown
Contributor

Thanks for the nice PR and the clear breakdown of your different commits 👍

Here's my first comment/question following Alex's suggestion on #3024 to:

Can you check what happens with config: {editable: true} when you drag the legend around and bump up against these various limits? Does the legend end up where you expected after you drop it?

@etpinard is this the expected behavior:

Peek 2019-09-03 12-26

@etpinard

Copy link
Copy Markdown
ContributorAuthor

Ok, here's what happens when you drag the legend to the right off the legend_negative_x mock under editable:true:

  • relayout with a new legend.x (and legend.y) is called on mouse up
  • the new automargin push values are set to 0 here:

Lib.log('Margin push',id,'is too big in x, dropping');

as the legend width is larger than half the layout width.

  • when the new legend.x is less than 1, this results in no right-margin push.
  • as we can't fit the legend within the graph's width with the new legend.x, we constrain its position here:

lx=Lib.constrain(lx,0,fullLayout.width-opts._width);

snapping the legend back to the left.


Now here's what happens when one drags the legend to the right past the plot area:

Peek 2019-09-03 14-46

here, the max horizontal legend width is computed with:

opts._maxWidth=Math.max(
isLeftOfPlotArea ? ((isBeyondPlotAreaX&&xanchor==='left') ? gs.l+gs.w : hw) :
isRightOfPlotArea ? ((isBeyondPlotAreaX&&yanchor==='right') ? gs.r+gs.w : hw) :
gs.w,

is fullLayout.width / 2, and the right-margin gets pushed.



It's not obvious to me how we should fix this problem. I'm thinking we could refine

Lib.log('Margin push',id,'is too big in x, dropping');

by considering the push-margin x (and y) position before "dropping" the push-margin values. In #3024, looks like @antoinerg tried to scale down the push-margin values instead of dropping them. If someone has a better idea, please let me know.

@antoinerg

antoinerg commented Sep 3, 2019

Copy link
Copy Markdown
Contributor
  • as we can't fit the legend within the graph's width with the new legend.x, we constrain its position here:

lx=Lib.constrain(lx,0,fullLayout.width-opts._width);

snapping the legend back to the left.

@etpinard It seems however that it does not snap back to the left enough: the item for trace 0 is clipped (the blue trace).

@etpinard

etpinard commented Sep 4, 2019

Copy link
Copy Markdown
ContributorAuthor

It seems however that it does not snap back to the left enough: the item for trace 0 is clipped (the blue trace).

Hmm. I can't replicate that. Can you check the fullLayout.legend.x value for which this happens? That would help me out a lot!

@antoinerg

Copy link
Copy Markdown
Contributor

Hmm. I can't replicate that. Can you check the fullLayout.legend.x value for which this happens? That would help me out a lot!

The value for gd._fullLayout.legend.x will depend on how far I drop the legend to the right. On Chrome and FF it reproducibly position the legend such that trace 0 is outside the figure and clipped. 🤔

@etpinard

Copy link
Copy Markdown
ContributorAuthor

Is this on the way to resolving #1199 or unrelated?

This PR fixes a bunch of positioning bugs for horizontal legends, so it should make it easier for users to workaround the problems of #1199. But this PR does not make an attempt at fixing axis label <--> legend overlaps.

@etpinard

Copy link
Copy Markdown
ContributorAuthor

@antoinerg thanks very much for spotting that bug!!

There was a race condition in Legend.draw - commit 42d5e4e fixes it.

Dragging around the legend_negative_x legend in editable:true mode now does this:

Peek 2019-09-10 15-17

which appears "ok" to me. There are still some less-than-ideal scenarios. These should be much easier to solve once #2704 is completed. Let me know what you think!

@antoinerg

Copy link
Copy Markdown
Contributor

This is a massive improvement for legend positioning. It seems like the several issues grouped in #771 are fixed as well as #4132 🎉

There are still some less-than-ideal scenarios.

I think the most confusing scenario for users is when they set legend.x to a value between 0 and 1. In this case, we don't expand the right margin and force the legend's position (as shown in your GIF). If the user really wants the legend to be at the provided legend.x, he/she will need to manually increase the right margin. It's probably OK for now but maybe we should improve on this once #2704 is completed.

One last non-blocking comment: I noticed that layout.margin.autoexpand doesn't have a description. It might be a good idea to add one either here or in #2704 .

Great job 💪 @etpinard

💃 💃 💃

@etpinard

Copy link
Copy Markdown
ContributorAuthor

Thanks very much for the review @antoinerg

I pushed d043151 adding a description for autoexpand

Merging.

@destradafilm

destradafilm commented Feb 18, 2020

Copy link
Copy Markdown

Has the suggested "fitinside" option ever been added? I've got an issue with legends crawling up to overlap the chart after resizing the window, and nothing in any of these commits or issues fixes it - but your codepen demoing "fitinside" on issue #771 had the correct behavior.

I still see the problem of either choosing the legend overlapping the chart or legend with cut off text when legend labels are long. Would it be possible to have an option to wrap text in the legend or overflow auto with a max-width and a scroll bar?

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugsomething broken

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ability to constrain legend background to the legend text Legend overlaps since 1.11.0

5 participants

@etpinard@nicolaskruchten@antoinerg@destradafilm@VerstandInvictus