Center date period as tick label between tick marks for time series plots

Time series labels are usually not the easiest to read, because plotting software treats dates as instant in time.
Is there a way to label time series ticks to show the time period rather than a point in time?

Take the following example;

The x tick labels are centred on the start of the month. When I go to look at a time series I want to know what time period it happened in, not what the start date of that period was. Surely everyone would rather see this x tick labelling;

You could apply this to a larger period of time (say calendar quarters). So instead of doing this;

You would do this;

Again, surely this is easier to read at a glance what time period something happened in rather than what the precise start date of that time period was?

You could take that logic and apply it to years in a decade, or days in a month (or hours in a day) and so on.

I can’t be the only one that finds the standard way of plotting time series non-intuitive and would rather see it the alternative way?

How could I go about implementing a change so all users could use this option?

hi @shoops
How about something like this example?

Hi @shoops,
I may have found what you need here.
The year is not centered, it is displayed below the first label, but that is a good formatting too.

You can add ticks also:

fig.update_xaxes(
    dtick="M1",
    tickformat="%b\n%Y",
    ticklabelmode="period",

    ticks="inside",
    minor=dict(
        ticks="outside",
        tickwidth=2,
        ticklen=30,
        dtick="M12",

    ),
)

And easily change the periods:

fig.update_xaxes(

    dtick="M3",
    tickformat="Q%q\n%Y",

    ticklabelmode="period",
    ticks="inside",
    minor=dict(
        ticks="outside",
        tickwidth=2,
        ticklen=30,
        dtick="M12",
    ),
)

5 Likes

Awesome, thanks @Skiks, that ticklabelmode="period" option was exactly what I was looking for. I didn’t know it existed!

I see the year part of tickformat is where the year label comes from, and it is only for the first ticklabel. I wonder why this is the case?

Also, I wonder why you can’t add an actual label for the minor ticks?

Any idea if ticklabelmode="period" works with tickformatstops?

1 Like

Thanks for the reply @adamschroeder. I’ll keep Enumerated ticks in mind.

Hi @shoops,
Glad to help as brand-new member of the forum! :tada:

It’s a kind of trick, like described in the previous example:

Date axis tick labels have the special property that any portion after the first instance of ‘\n’ in tickformat will appear on a second line only once per unique value, as with the year numbers in the example below. To have the year number appear on every tick label, '<br>' should be used instead of ‘\n’.


Indeed I didn’t find a way to display labels on minor ticks :thinking:


I ran some tests using this example, it seems that they don’t play well together

Vanilla

With ticklabelmode="period"
It seems the labels shift a little, but not in the center of the periods

With ticklabelmode="period" and dtick="M1"
The labels come back on ticks

Zooming to have horizontal labels

Anyway, it seems it is not possible to define custom tick periods depending on the zoom level.
tickformatstops only allows to define only labels format depending on the zoom level

Maybe the solution can be to create a kind of custom tickformatstops using Dash and Graph prop relayoutData as input of a callback, which is triggered with zoom/pan. See exemple here

2 Likes

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)",
)