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?
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:
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()
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:
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 SubplotRef
s 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 ]
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?
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:
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()
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:
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 SubplotRef
s 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 ]