Search code examples
pythonplotlyplotly-pythonfacet-grid

Left Align the Titles of Each Plotly Subplot


I have a facet wraped group of plotly express barplots , each with a title. How can I left align each subplot's title with the left of its plot window? enter image description here

import lorem
import plotly.express as px
import numpy as np
import random

items = np.repeat([lorem.sentence() for i in range(10)], 5)
response = list(range(1,6)) * 10
n = [random.randint(0, 10) for i in range(50)]

(
    px.bar(x=response, y=n, facet_col=items, facet_col_wrap=4, height=1300)
    .for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1]))
    .for_each_xaxis(lambda xaxis: xaxis.update(showticklabels=True))
    .for_each_yaxis(lambda yaxis: yaxis.update(showticklabels=True))
    .show()
)

I tried adding .for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1], x=0)) but it results in: enter image description here


Solution

  • There's a few major challenges here:

    • annotations are not aware of their column in the facet plot (which makes determining the x value difficult)
    • the order in which annotations are created are from left to right, bottom to top (therefore, the annotations at the bottom of the plot are created first)
    • if the number of subplots doesn't divide the number of columns evenly, then the first {remainder} number of annotations are made in the bottom row, and then subsequent annotations start the next row up, where the {remainder} = {number of annotations} % {number of columns}

    To help visualize:

    x x x x
    x x x x ⭠ followed by these ones left to right, ...
    x x     ⭠ these annotations are created first left to right
    

    This means we would like to iterate through (annotation1, x_location_of_col1), (annotation2, x_location_of_col2), (annotation3, x_location_of_col1)... because the placement of the title is based on knowing when the column restarts.

    In your facet plot there are 10 annotations, 12 total subplots, and 4 columns. Therefore we want to keep track of the x values for each subplot which we can extract from the layouts: the information inside fig.layout['xaxis'], fig.layout['xaxis2']... contain the starting x-values for each subplot in paper coordinates, and we can use the information up to 'xaxis4' (since we have 4 columns), and store this info inside x_axis_start_positions which in our case is [0.0, 0.255, 0.51, 0.7649999999999999] (this is derived in my code, we definitely don't want to hardcode this)

    Then using the fact that there are 4 columns, 10 annotations, and 12 plots, we can work out that there are 10 // 4 = 2 full rows of plots, and the first row has 10 % 4 = 2 plots in the first row of annotations that are created.

    We can distill this information into the x starting positions we iterate through for the placement of each title:

    x_axis_start_positions_iterator = x_axis_start_positions[:remainder] + x_axis_start_positions*number_of_full_rows 
    # [0.0, 0.255, 0.0, 0.255, 0.51, 0.7649999999999999, 0.0, 0.255, 0.51, 0.7649999999999999]
    

    Then we iterate through all 10 annotations and 10 starting positions for the titles, overwriting the positions of the automatically generated annotations.

    Update: I have wrapped this in a function called left_align_facet_plot_titles that takes a facet plot fig as an input, figures out the number of columns, and left aligns each title.

    import lorem
    import plotly.express as px
    import numpy as np
    import random
    
    items = np.repeat([lorem.sentence() for i in range(10)], 5)
    response = list(range(1,6)) * 10
    n = [random.randint(0, 10) for i in range(50)]
    
    fig = (
        px.bar(x=response, y=n, facet_col=items, facet_col_wrap=4, height=1300)
        .for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1]))
        .for_each_xaxis(lambda xaxis: xaxis.update(showticklabels=True))
        .for_each_yaxis(lambda yaxis: yaxis.update(showticklabels=True))
    ) 
    
    def left_align_facet_plot_titles(fig):
    
        ## figure out number of columns in each facet
        facet_col_wrap = len(np.unique([a['x'] for a in fig.layout.annotations]))
    
        # x x x x
        # x x x x <-- then these annotations
        # x x     <-- these annotations are created first
    
        ## we need to know the remainder
        ## because when we iterate through annotations
        ## they need to know what column they are in 
        ## (and annotations natively contain no such information)
    
        remainder = len(fig.data) % facet_col_wrap
        number_of_full_rows = len(fig.data) // facet_col_wrap
    
        annotations = fig.layout.annotations
    
        xaxis_col_strings = list(range(1, facet_col_wrap+1))
        xaxis_col_strings[0] = ''
        x_axis_start_positions = [fig.layout[f'xaxis{i}']['domain'][0] for i in xaxis_col_strings]
    
        if remainder == 0:
            x_axis_start_positions_iterator = x_axis_start_positions*number_of_full_rows
        else:
            x_axis_start_positions_iterator = x_axis_start_positions[:remainder] + x_axis_start_positions*number_of_full_rows
    
        for a, x in zip(annotations, x_axis_start_positions_iterator):
            a['x'] = x
            a['xanchor'] = 'left'
        fig.layout.annotations = annotations
        return fig
    
    fig = left_align_facet_plot_titles(fig)
    fig.show()
    

    enter image description here

    And if we change the number of columns in the figure with fig = px.bar(..., facet_col_wrap=3, ...) and pass this figure to the function, the results are also as expected:

    enter image description here