🧑‍💻 Interactive app to explain legend and annotations positioning

Hi Everyone,
Since we get questions about legend/annotation positioning, my colleague @celia created an app to show the community how annotations and legend position options work. Just run the code located below on your computer and see how the app’s code snippet changes, based on the positions you want.

annotations

Show Code! (Click to expand)
import dash
from dash import Dash, dcc, html, Input, Output, State, MATCH, ALL
import dash_bootstrap_components as dbc
import dash_mantine_components as dmc
import plotly.express as px
import plotly.graph_objects as go

import pandas as pd
import json

app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])

# plot creation
df_plot = px.data.tips()

fig = px.scatter(df_plot, x="total_bill", y="tip", color="smoker")
fig.add_annotation(
    text='annotation 1',
    x=0, y=0,
    xref='paper',
    yref='paper',
    showarrow=False,
    bordercolor='darkred',
    bgcolor='white',
    borderwidth=2
)
fig.update_layout(legend=dict(bordercolor = 'red', borderwidth=2))

#### anchor fig ####
anchor_fig = go.Figure()

anchor_fig.add_trace(go.Scatter(
    x=[1, 1, 1, 5, 5, 5, 9, 9, 9],
    y=[1, 5, 9, 1, 5, 9, 1, 5, 9],
    mode="markers",
    marker_size=10
))

anchor_fig.add_trace(go.Scatter(
    x=[0.5, 0.5, 0.5, 2, 5, 8],
    y=[2, 5, 8, 10, 10, 10],
    text = ['yref="bottom"', 'yref="middle"', 'yref="top"', 'xref="left"', 'xref="center"', 'xref="right"'],
    mode="text",
))

# Add shapes
for i in range(3) :
    for j in range(3):
        anchor_fig.add_shape(type="rect",
            x0=1+3*i, x1=3+3*i, y0=1+3*j, y1=3+3*j, 
            line=dict(color="RoyalBlue"),
            xref='x', yref='y',
        )

anchor_fig.update_layout(
    xaxis={'showgrid':False, 'showticklabels':False},
    yaxis={'showgrid':False, 'showticklabels':False},
    plot_bgcolor='rgba(0,0,0,0)',
    paper_bgcolor='rgba(0,0,0,0)',
    margin={'b':0, 't':0},
    showlegend=False
)

#### utilities for layout ####
default_values =  {
    'xanchor':'center',
    'x':1,
    'xref':'paper',
    'yanchor':'middle',
    'y':1,
    'yref':'paper'
}

def legend_code(values=default_values) :
    return f'''
# legend
fig.update_layout(
    legend=dict(
        x={values.get('x')}, 
        xanchor="{values.get('xanchor')}", 
        y={values.get('y')}, 
        yanchor="{values.get('yanchor')}"
    )
)
        '''

def annotation_code(values=default_values) :
    return f'''
# annotation
fig.update_annotations(
    x={values.get('x')}, 
    xanchor="{values.get('xanchor')}",
    xref="{values.get('xref')}", 
    y={values.get('y')}, 
    yanchor="{values.get('yanchor')}",
    yref="{values.get('yref')}",
)
        '''

intro_text = '''
    This app allows you to play around with the relevant arguments for annotation and legend positioning in Plotly plots, so that you can:
    
    * Generate and copy the code to update your plot with that legend/annotation position.
    * Understand better how this arguments work in the background.

    If you want to learn more about annotation and legend positioning, click on the arrow under this text to show an explanation.
    
    If you want to learn more about each argument, click on the button with its name and a short description will appear under it.
'''

explanation_text = ['''
    When we talk about changing their position, legends are just a special type of annotations, so both are modified in the same way.
    To understand better how their arguments work, it's easier to **think about them like boxes**.
    There's a point inside each annotation box that is used as a reference by the arguments `x` and `y`.
    When we change `xanchor` or `yanchor`, we change the position of that point inside the box. 
    This is useful if we want to specify that an annotation needs to start at a certain point (`xanchor="left"`), end at that point (`xanchor="right"`) 
    or be centered at that point (`xanchor="center"`). And the same for `yanchor`, but in the vertical axis.
    ''',
    '''
    When we change `xref` or `yref`, we change the type of axis where the annotation box (and its anchor point) will be placed:

    * `xref="x"` and `yref="y"` are useful when we want to place the annotation inside the plot grid, with the rest of the traces. 
    In that case, `x` and `y` mean the same position as when we indicate x and y for a point in a scatterplot.
    * `xref="paper"` and `yref="paper"` are useful when we want to place the annotation outside the plot grid, in the area around it. 
    The points 0 and 1 indicate the corners of the grid, so any value <0 or >1 will place the annotation outside the plot grid. 
    Any value between 0 and 1 will place it inside the plot grid, but `x=0.5` will mean the middle point of the plot grid, not x=0.5 in the plot axis.
    If the xaxis range is \\[0,4\\], the annotation will be in xaxis=2, not xaxis=0.5.
    * `xref="x domain"` and `yref="y domain"` are useful when we have subplots and we want to apply the logic  of `xref="paper"` and `yref="paper"` only to one of the subplots.
    For example, if we wanted to put the annotation in the middle of the second (vertically stacked) subplot, we would specify: `yref="y2 domain"` and `y="0.5`.

    These arguments don't appear when we are modifying the legend because for the legend `xref` or `yref` are always equeal to `"paper"` 
    (they are always outside the plot grid and aren't affected by subplots)
''']

collapse_text = {
    'annotation' : dcc.Markdown('''
        More information about Legends: [Examples](https://plotly.com/python/legend/)   [Full reference](https://plotly.com/python/reference/layout/#layout-legend) \n
        More information about Annotations: [Examples](https://plotly.com/python/text-and-annotations/)  [Full reference](https://plotly.com/python/reference/layout/annotations/)
        '''),
    'xanchor' : '''
        Horizontal position of the anchor inside the annotation box.
    ''' ,
    'yanchor' : '''
        Vertical position of the anchor inside the annotation box.
    ''' ,
    'x' : '''
        Horizontal position of the annotation box anchor in the whole figure area. 
        0 is the left side of the plot grid and 1 is the left side of the plot grid, 
        but `x` can take negative values and values higher than 1.
    ''' ,
    'y' : '''
        Vertical position of the annotation box anchor in the whole figure area.
        0 is the bottom of the plot grid and 1 is the top of the plot grid, 
        but `y` can take negative values and values higher than 1.
    ''' ,
    'xref' : '''
        Which type of x axis is used for the x position: 
        * x axis id (e.g. `"x"` or `"x2"`): the `x` position refers to a x coordinate inside the plot grid. 
        * "paper": the `x` position refers to the distance from the left of the plotting area in normalized coordinates where "0" ("1") corresponds to the left (right). 
        * x axis ID followed by "domain" (separated by a space - e.g. "x domain"): the position behaves like for "paper". It only makes a difference for **subplots**.  
        With `xref="x2 domain"` the `x` value refers to the distance in fractions from the left of the domain of that axis: 
        e.g., `x=0.5` refers to the middle point between the left and the right of the domain of the second x axis.
    ''' ,

    'yref':'''
        Which type of y axis is used for the x position: 
        * y axis id (e.g. `"y"` or `"y2"`): the `y` position refers to a y coordinate inside the plot grid. 
        * "paper": the `y` position refers to the distance from the left of the plotting area in normalized coordinates where "0" ("1") corresponds to the left (right). 
        * x axis ID followed by "domain" (separated by a space - e.g. "x domain"): the position behaves like for "paper". It only makes a difference for **subplots**.  
        With `xref="x2 domain"` the `x` value refers to the distance in fractions from the left of the domain of that axis: 
        e.g., `x=0.5` refers to the middle point between the left and the right of the domain of the second x axis.
    '''

}

def control_content(index, control_item, label=None, text=None, link=None, collapse_children=None) :

    if label is None :
            label = index

    if collapse_children is None :
        if link is None :
            link = 'https://plotly.com/python/reference/layout/annotations/#layout-annotations-items-annotation-' + index
        if text is None:
            text = collapse_text.get(index)
        collapse_children = [
                dcc.Markdown(text),
                html.A("Check in Plotly Docs", href=link, target="_blank")
                ]

    component = [
        dbc.Row([
            dbc.Col(
                dbc.Button(
                    label, id={'type':'button', 'index':index}, style={'width':'-webkit-fill-available'}), 
                    width = 3, style={'margin-top':'10px'}),
            dbc.Col(
                control_item, width = 9, 
                style={'align-self': 'center','margin-top': '10px'})
            ], id={'type':'container', 'index':index}, style={'display':'flex'}),
        dbc.Row(dbc.Collapse(
            dbc.Card(dbc.CardBody(collapse_children)), id={'type':'collapse', 'index':index}, is_open=False), style={'margin-top':'10px'})
        ]
    
    return component

# layout
app.layout = html.Div([
    dbc.Row([
        dbc.Accordion(dbc.AccordionItem(
            title='Plotly legend and annotations tutorial',
            children=dcc.Markdown(intro_text, style={'margin':'15px'})
        )),
        dbc.Accordion(dbc.AccordionItem(
            title='Show explanation',
            children=dbc.CardBody([
                dcc.Markdown(explanation_text[0]),
                dcc.Graph(figure=anchor_fig),
                dcc.Markdown(explanation_text[1])
            ])
        ), start_collapsed=True)
    ]),
    dbc.Row([
        dbc.Col([
            dbc.Card(dbc.CardBody(
                control_content(
                    'annotation',
                    dcc.RadioItems(
                        ['legend', 'annotation 1'], 'legend', id={'type':'control-item', 'index':'annotation'},
                        inline= True,
                        labelStyle = {'margin-right':'25px'},
                        inputStyle={"margin-right": "5px"}
                        ),
                    collapse_children=collapse_text.get('annotation')
                    )
            ), style={'margin':'10px', 'margin-bottom':'0px', 'margin-left':'0px'}),
            dbc.Card(dbc.CardBody(
                # xanchor
                control_content(
                    'xanchor',
                    dcc.RadioItems(
                        ['center', 'left', 'right'], default_values.get('xanchor'), 
                        id={'type':'control-item', 'index':'xanchor'},
                        inline= True,
                        labelStyle = {'margin-right':'25px'},
                        inputStyle={"margin-right": "5px"}
                    )
                ) +   
                # x-slider   
                control_content(
                    'x',
                    dcc.Slider(
                        -0.5,1.5,
                        step=None,
                        value=default_values.get('x'),
                        id={'type':'control-item', 'index':'x'}
                    )
                ) +     
                # xref
                control_content(
                    'xref',
                    dcc.RadioItems(
                        ['paper','x'], default_values.get('xref'), 
                        id={'type':'control-item', 'index':'xref'},
                        inline= True,
                        labelStyle = {'margin-right':'25px'},
                        inputStyle={"margin-right": "5px"}
                        ),
                ) 
            ), style={'margin':'10px', 'margin-bottom':'0px', 'margin-left':'0px'}),
            # y settings
            dbc.Card(dbc.CardBody(
                # yanchor
                control_content(
                    'yanchor',
                    dcc.RadioItems(
                        ['middle', 'bottom', 'top'], default_values.get('yanchor'), 
                        id={'type':'control-item', 'index':'yanchor'},
                        inline= True,
                        labelStyle = {'margin-right':'25px'},
                        inputStyle={"margin-right": "5px"}
                        ),
                ) +   
                # y-slider   
                control_content(
                    'y',
                    dcc.Slider(
                        -0.5,1.5,
                        step=None,
                        value=default_values.get('y'),
                        id={'type':'control-item', 'index':'y'}
                    )
                ) +     
                # yref
                control_content(
                    'yref',
                    dcc.RadioItems(
                        ['paper','y'], default_values.get('yref'), 
                        id={'type':'control-item', 'index':'yref'},
                        inline= True,
                        labelStyle = {'margin-right':'25px'},
                        inputStyle={"margin-right": "5px"}
                        ),
                ) 
            ), style={'margin':'10px', 'margin-bottom':'0px', 'margin-left':'0px'})
        ], lg = 6, md = 12), # https://subscription.packtpub.com/book/data/9781800568914/2/ch02lvl1sec09/learning-how-to-structure-the-layout-and-managing-themes
        dbc.Col([
            dbc.Card(dbc.CardBody([
                dcc.Graph(id='graph', figure=fig),
                html.Br(),
                dbc.Row(style={'margin':'5px'}, children=
                    dmc.Prism(
                        language='python', id='code',
                        children = legend_code()
                        )
                )
            ]), style={'margin':'10px', 'margin-right':'0px', 'margin-left':'0px'})
        ], lg = 6, md = 12)      
    ])
], style={'margin':'10px'})

# Callbacks

@app.callback(
    Output('graph', 'figure'),
    Output({'type':'control-item', 'index':ALL}, 'value'),
    Output('code', 'children'),
    Input({'type':'control-item', 'index':ALL}, 'value'),
    State('graph', 'figure'),
    prevent_initial_call=True
    )
def update_figure(*args):

    current_state =  {json.loads(k.split('.')[0])['index']:v for k, v in dash.callback_context.inputs.items()}
    trigger = json.loads(dash.callback_context.triggered[0]['prop_id'].split('.')[0])['index']

    fig = args[1]
    fig = go.Figure(fig)

    if current_state.get('annotation') == 'legend' :
        if trigger  == 'annotation' :
            for i in ['xanchor', 'yanchor', 'x', 'y']:
                current_state[i] = fig['layout']['legend'][i] if fig['layout']['legend'][i] else default_values.get(i)
        else  :
            fig.update_layout(legend={
                trigger:current_state.get(trigger)
            })
        code = legend_code(current_state)

    elif current_state.get('annotation') == 'annotation 1' :
        if trigger  == 'annotation' :
            for i in default_values.keys():
                current_state[i] = fig['layout']['annotations'][0][i] if fig['layout']['annotations'][0][i] else default_values.get(i)
        else  :
            fig['layout']['annotations'][0][trigger] = current_state.get(trigger)

        code = annotation_code(current_state)

    current_state  = list(current_state.values())

    return fig, current_state, code

@app.callback(
    Output({'type':'collapse', 'index':MATCH}, 'is_open'),
    Input({'type':'button', 'index':MATCH}, 'n_clicks'),
    State({'type':'collapse', 'index':MATCH}, 'is_open'),
    prevent_initial_call = True
)
def update_figure(click, is_open):
    if click :
        return not is_open

@app.callback(
    Output({'type':'container', 'index':'xref'}, 'style'),
    Output({'type':'container', 'index':'yref'}, 'style'),
    Input({'type':'control-item', 'index':'annotation'}, 'value'),
    State({'type':'container', 'index':'xref'}, 'style'),
    State({'type':'container', 'index':'yref'}, 'style'),
)
def update_figure(value, x_style, y_style):
    if value == 'legend' :
        x_style['display'] = 'none'
        y_style['display'] = 'none'
    else :
        x_style['display'] = 'flex'
        y_style['display'] = 'flex'

    return x_style, y_style

if __name__ == '__main__':
    app.run_server(debug=True)

6 Likes

Hi @adamschroeder

Really cool and useful. Thanks.

A question:

what should we do for scatters markers text? can we change their position numerically? (Instead of using positions name like left right and …)

Hi Bijan! I’m glad you find it useful.

Regarding to your question: I’m not. sure I understand what you would like to do. The text labels are assigned to the x, y values of the corresponding marker and you can modify their relative position with textposition: Scatter traces in Python

That argument would be similar to combining xanchor and yanchor for an annotation, and that’s why it has values like ‘left bottom’. But in that case what we specify is the position of the label relative to the marker (we want the label to appear on the left side of the marker and below it).

If you want text labels to appear in other places, but you don’t want to use annotations, you can create a Scatter trace with mode=‘text’, specifying in x and y the list/array of positions. Actually, that’s what I used for the anchor plot of the app:

anchor_fig.add_trace(go.Scatter(
    x=[0.5, 0.5, 0.5, 2, 5, 8],
    y=[2, 5, 8, 10, 10, 10],
    text = ['yref="bottom"', 'yref="middle"', 'yref="top"', 'xref="left"', 'xref="center"', 'xref="right"'],
    mode="text",
))
1 Like

@celia
Really Thanks. That was again cool. I was searching for this and you solved it great. That was Really intelligent and thanks for the sample you shared. :+1: :pray:

1 Like

Such a great tool!

2 Likes

It’s now deployed to Heroku! :tada: https://plotly-annotations.herokuapp.com/

4 Likes

Just used this tool to help me position a legend, very useful!

Would it be possible to add a switch for the orientation (vertical or horizontal)? That would make it even more useful!

1 Like

Hi @yanboe ! Thank you for the feedback. I didn’t think about the legend orientation but it’s a good point, I will work on implementing that change :slight_smile:

2 Likes

The app has just been updated to include legend orientation! :v:

2 Likes