Left Align the Titles of Each Plotly Subplot

Question:

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

Asked By: Tyler Rinker

||

Answers:

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

Answered By: Derek O

One way is to set xref with axes id instead of ‘paper’ so that titles can be left-aligned with respect to the x coordinate of the corresponding xaxis.

Yet as mentioned by @Derek O, the tricky part is how to identify the annotations as we iterate over it. Given that the input data are indexed (the i in for i in range(10) is fine), you can use this index to retrieve the corresponding xref when iterating, the only requirement is to keep (or create) the mapping between them, to illustrate this here I put the index in the subplot titles directly :

K = 10
R = 5

items = np.repeat([f'{i}={lorem.sentence()}' for i in range(K)], R)
response = list(range(1,6)) * K
n = [random.randint(0, 10) for i in range(K*R)]

facet_col_wrap = 4
nrows = math.ceil(K / facet_col_wrap)
ncols = min(K, facet_col_wrap)

# Nb. We need to read fig._grid_ref for creating the xrefs
fig = px.bar(x=response, y=n, facet_col=items, facet_col_wrap=facet_col_wrap, height=1300)

xrefs = []
for r in range(nrows)[::-1]:
    for c in range(ncols):
        subplot_ref = fig._grid_ref[r][c][0]
        xrefs.append(subplot_ref.trace_kwargs['xaxis'])

def updateAnnotation(a):
    _, i, title = a.text.split("=")
    a.update(text=title, xanchor='left', x=0, xref=xrefs[int(i)])

(
    fig
    .for_each_annotation(updateAnnotation)
    .for_each_xaxis(lambda xaxis: xaxis.update(showticklabels=True))
    .for_each_yaxis(lambda xaxis: xaxis.update(showticklabels=True))
    .show()
)

Inspecting the SubplotRefs in fig._grid_ref is quite helpful, one SubplotRef looks like :

SubplotRef(subplot_type='xy', layout_keys=('xaxis', 'yaxis'), trace_kwargs={'xaxis': 'x', 'yaxis': 'y'})

Even better :

>>> fig.print_grid()

This is the format of your plot grid:
[ (3,1) x9,y9   ]  [ (3,2) x10,y10 ]  [ (3,3) x11,y11 ]  [ (3,4) x12,y12 ]
[ (2,1) x5,y5   ]  [ (2,2) x6,y6   ]  [ (2,3) x7,y7   ]  [ (2,4) x8,y8   ]
[ (1,1) x,y     ]  [ (1,2) x2,y2   ]  [ (1,3) x3,y3   ]  [ (1,4) x4,y4   ]
Answered By: EricLavault