# Behavior of tickformatstops - what is zoom level?

Can any please explain the behaviour of tickformatstops?

In the example “Time series and date axes in Python”, it says; “The `tickformatstops` attribute can be used to customize the formatting of tick labels depending on the zoom level.”

In the reference, the ‘dtickrange’ argument defines the “min”, “max” - dtick values which describe some zoom level.

What value needs to be inbetween the min/max interval to actually set the “zoom level”?
I would have thought it would be the x range of the axis, i.e. if there is more than one month and less than twelve months of data shown then it would be this level;

``````dict(dtickrange=["M1", "M12"], value="%b '%y M"),
``````

but instead it seems to be this level;

``````dict(dtickrange=[604800000, "M1"], value="%e. %b w"),
``````

It looks like this;

Can anyone please help me with the logic here? What is greater than one week (604800000) and less than one month (M12)?

Hey @shoops !
(Yes me again )
Actually I think `dtickrange` is the range between ticks not x axis range.

Setting `dtick="M1"`, `dtickrange=[604800000, "M1"], value="%e. %b w"` will be used

Setting `dtick="M2"`, `dtickrange=["M1", "M12"], value="%b '%y M"` will be used

Thanks @Skiks. You are setting `dtick` in the code, but isn’t the point of `tickformatstops` that the user can using the mouse cursor to set the “zoom level” to a given range?

So at first, when you’re looking at the full span of the whole time series, it makes sense for the range between x ticks to be wide. But as you zoom in, you want to see smaller (and more meaningful) x tick ranges. I.e;

My question is more like the following;
When the user manually chooses the x range, which “zoom level” in `tickformatstops` applies?

Hi @shoops

Yes you are right, `dtick` and `tickformatstops` are not meant to work together, it was only for demonstration purpose

`tickformatstops` probably works with `tickmode='auto'`, the range between 2 ticks is calculated automatically.
As you zoom, this range will change automatically if needed, and when you see it change, it can be in a different `dtickrange` you defined in `tickformatstops` and thus adapt the tick label format by the `value` corresponding to this `dtickrange`.

When you start:

The range between 2 ticks is 3 month, we are in `dtickrange=["M1", "M12"]`, the tick label format “%b '%y M” applies like “Apr '15 M”

When you zoom in, the range between ticks changes:

The range between 2 ticks switch automatically to 2 month, we are still in `dtickrange=["M1", "M12"]`, the tick label format “%b '%y M” still applies like “Mar '15 M”

Still zoom in:

The range between 2 ticks switch automatically to 1 month, this time we are in `dtickrange=[604800000, "M1"]`, (seem to be left side of range excluded and right side included), thus the tick label format “%e. %b w” still applies like “1. Mar w”

I think you get it

Thanks again @Skiks, I really appreciate your help.

-Why did it go from tick spaces being ‘M3’ to ‘M2’? - who said anything about ‘M2’ spacing?
-When it is ‘M2’ why is it Mar, May, Jul etc rather than Apr, Jun, Aug etc
-When you zoomed from ‘M2’ to ‘M1’, what was it that prompted plotly just choose the next dtickrange? Was it because it is trying to fit 7-9 full tick spaces?

Actually, the last question is what I’m trying to understand/control.

^This is what I’m trying to control.

How do I tell plotly that at a given zoom level (i.e. x range), I want tick spacing to be XYZ, with formatting ABC and (as a bonus, what the first tick should be; i.e. start of the calendar quarter, or calendar month, or ISO week or whatever)

It’s plotly the boss
When you don’t set specific constrains (like dtick=“M1”), plotly is programmed to make a nice looking axis.
It kind of set for you `tick0` the starting position and `dtick` the range between two ticks.
You can constrain a little the automatic mode by setting `nticks` the max number of ticks, plotly will still set the starting position and the tick range, but will manage to have as many ticks as possible to be close to `nticks`

No it is exactly because of the ranges `dtickrange` defined in `tickformatstops`.
But ‘M1’ is not obvious as is, as it is a boundary of 2 ranges `[604800000, "M1"]` and `["M1", "M12"]`,
as I stated above

by looking on the result on the displayed plot, meaning ‘M1’ seems to be considered in range `[604800000, "M1"]`

Like suggested in your other post, I think the solution is to use Dash and Graph prop `relayoutData` as input of a callback, which is triggered with zoom/pan.

I am going to try to cook you an example

``````from datetime import timedelta, datetime

from dash import Dash, dcc, html, Input, Output, Patch, State
import plotly.express as px

df = px.data.stocks()
fig = px.line(df, x="date", y=df.columns)
fig.update_xaxes(
rangeslider_visible=True,
ticklabelmode="period",
ticks="inside",
minor=dict(
ticks="outside",
tickwidth=2,
ticklen=30,
),
)

app = Dash(__name__)
app.layout = html.Div([
dcc.Graph(id='fig', figure=fig),
])

# little helper to convert in ms
to_ms = {
'H': 1000 * 60 * 60,
'D': 1000 * 60 * 60 * 24,
'W': 1000 * 60 * 60 * 24 * 7,
}

@app.callback(
Output('fig', 'figure'),
Input('fig', 'relayoutData'), # Triggered by zooming/panning on figure
State('fig', 'figure'),
)
def update_xticks(_, figure):
# find x_range
start_datetime = datetime.fromisoformat(figure["layout"]["xaxis"]["range"][0])
end_datetime = datetime.fromisoformat(figure["layout"]["xaxis"]["range"][1])
range_timedelta = end_datetime - start_datetime

# 5Y <= x_range
if timedelta(days=365 * 5) <= range_timedelta:
xaxis = dict(dtick="M12", tickformat="%Y\n ", minor_dtick="M12")

#  1.5Y <= x_range < 5Y
elif timedelta(days=365 * 1.5) <= range_timedelta < timedelta(days=365 * 5):
xaxis = dict(dtick="M3", tickformat="Q%q\n%Y", minor_dtick="M12")

#  4M <= x_range < 1.5Y
elif timedelta(days=30 * 4) <= range_timedelta < timedelta(days=365 * 1.5):
xaxis = dict(dtick="M1", tickformat="%b\nQ%q '%y", minor_dtick="M3")

#  30D <= x_range < 4M
elif timedelta(days=30) <= range_timedelta < timedelta(days=30 * 4):
xaxis = dict(dtick=to_ms['W'], tickformat="S%U\n%b '%y", minor_dtick="M1")

#  7D <= x_range < 4M
elif timedelta(days=7) <= range_timedelta < timedelta(days=30):
xaxis = dict(dtick=to_ms['D'], tickformat="%e\nS%U %b '%y", minor_dtick=to_ms['W'])

#  1D <= x_range < 7D
elif timedelta(days=1) <= range_timedelta < timedelta(days=7):
xaxis = dict(dtick=12 * to_ms['H'], tickformat="%p\n%e. %b '%y", minor_dtick=to_ms['D'])

#  x_range < 1D
else:
xaxis = dict(dtick=to_ms['H'], tickformat="%H:%M\n%p</br>%e. %b '%y", minor_dtick=12 * to_ms['H'])

# Partial figure update using brand new Patch() feature :)
patched_figure = Patch()
patched_figure["layout"]["xaxis"]["dtick"] = xaxis["dtick"]
patched_figure["layout"]["xaxis"]["tickformat"] = xaxis["tickformat"]
patched_figure["layout"]["xaxis"]["minor"]["dtick"] = xaxis["minor_dtick"]

return patched_figure

if __name__ == '__main__':
app.run_server(debug=True)

``````

Enjoy!

1 Like