2025 UPDATE:
It still bugged me that the years are not centered in the accepted solution so Iâve searched for alternatives. Plotlyâs Multi-categorical Axes wonât order months ascending for corner cases where at least 1 year doesnât have full 12-month data.
Inspired by this annotation approach & matplotlibâs secondary xaxis approach and with the help from AI, Iâve come up with a workaround to center the years by placing them below the months as text annotations using âpaper coordinatesâ (docs) then drawing the grouping lines using minor ticks. Itâs not perfect but is close enough for my needs so I thought Iâd share this workaround for anyone who might find themselves in a similar situation like I did.
Drawback: when panning or zooming in, the years might disappear as their x-coordinates are relative to the xaxis scale when using xref="x"
. I think this can be fixed by approaching FigureWidgetâs layout.on_change
or Dashâs relayoutData
to recalculate the xaxis range. The below reproducible code doesnât fix this drawback as the potential fix seems slightly complicated for my use case.
Output:
Reproducible example:
import numpy as np
import pandas as pd
import plotly.graph_objects as go
# Generate sample data:
np.random.seed(42) # for reproducibility
num_months = 25
dates = pd.date_range("2020-05-01", periods=num_months, freq="MS")
revenue = np.random.randint(50000, 150000, size=num_months)
df = pd.DataFrame({"month": dates, "revenue": revenue})
# Plot:
fig = go.Figure()
fig.add_trace(go.Bar(x=df["month"], y=df["revenue"]))
# Calculate the OUTER minor ticks' x-coordinates for year grouping lines:
offset = pd.DateOffset(17) # prevent lines from overlapping with major tick labels
x_min = min([min(trace_data.x) for trace_data in fig.data]) - offset
x_max = max([max(trace_data.x) for trace_data in fig.data]) + offset
minor_ticks = [x_min, x_max]
# Calculate the years' x-coordinates:
years = df["month"].dt.year.unique()
for year in years:
df_year = df[df["month"].dt.year == year]
# Calculate the years' x-coordinates at the center points:
midpoint = (
df_year["month"].min() + (df_year["month"].max() - df_year["month"].min()) / 2
)
# Calculate the INNER minor ticks' x-coordinates for year grouping lines by excluding the last year to avoid duplicate overlapping lines from OUTER minor ticks:
if year != years.max():
minor_ticks.append(df_year["month"].max() + offset)
# Add the year labels as annotations:
fig.add_annotation(
xref="x", # Relatively-positioned annotation: x-coordinates are with respect to xaxis
yref="paper", # Absolutely-positioned annotation
x=midpoint,
y=-0.15,
text=str(year),
showarrow=False,
)
fig.update_xaxes(
dtick="M1",
tickformat="%b",
tickangle=0,
minor={"tickvals": minor_ticks, "ticklen": 50, "tickcolor": "gray"}, # draw lines
range=[x_min, x_max], # extend x-axis limits to match minor ticks range
showline=True,
linecolor="gray",
)
fig.update_layout(
yaxis={"showgrid": False, "tickprefix": "$", "ticksuffix": " "},
plot_bgcolor="rgba(0,0,0,0)",
)