Y-axis autoscaling with x-range sliders

Afaik, y-axis cant be made to auto scale when using x-range sliders. Y range is chosen with respect to the y values of the whole x range and does not change after zooming-in. This is especially annoying with candlestick charts in volatile periods. When you zoom-in using x-range slider, you essentially get flat candlesticks as their fluctuations only cover a very small part of the initial range. After doing some research it seems that some progress has been made here: https://github.com/plotly/plotly.js/pull/2364. Anyone knows if there is a working solution for plotly.py ? I think the team/devs should really look into it, candlestick demo is the first example on the official site, yet y-axis autoscale does not work which makes the whole demonstration look kinda weird.

Thank you for your time.

1 Like

Did you find a way to resolve this issue?

@TOTORO @chriddyp or anyone else, is there any working solution? setting animate=False did not work… :confused:

Hi @TOTORO and @s1kor,

I came up with a Python solution to this using the new FigureWidget class. The basic idea is the register a Python callback function to run whenever the xaxis range changes. In response, the callback function computes and applies a new yaxis range.

First construct a simple scatter plot line plot with a range slider. Make sure to set the

import plotly.graph_objs as go 

from datetime import datetime
import pandas_datareader.data as web

df = web.DataReader('AAPL.US', 'quandl',
                    datetime(2007, 10, 1),
                    datetime(2009, 4, 1))

# Make sure dates are in ascending order
# We need this for slicing in the callback below
df.sort_index(ascending=True, inplace=True)

trace = go.Scatter(x=list(df.index),
                   y=list(df.High))

data = [trace]
layout = dict(
    title='Time series with range slider and selectors',
    xaxis=dict(
        rangeselector=dict(
            buttons=list([
                dict(count=1,
                     label='1m',
                     step='month',
                     stepmode='backward'),
                dict(count=6,
                     label='6m',
                     step='month',
                     stepmode='backward'),
                dict(count=1,
                    label='YTD',
                    step='year',
                    stepmode='todate'),
                dict(count=1,
                    label='1y',
                    step='year',
                    stepmode='backward'),
                dict(step='all')
            ])
        ),
        rangeslider=dict(
            visible = True
        ),
        type='date'
    )
)

fig = go.FigureWidget(data=data, layout=layout)
fig

Then install a property change callback to update the yaxis range

def zoom(layout, xrange):
    in_view = df.loc[fig.layout.xaxis.range[0]:fig.layout.xaxis.range[1]]
    fig.layout.yaxis.range = [in_view.High.min() - 10, in_view.High.max() + 10]

fig.layout.on_change(zoom, 'xaxis.range')

Now, when you manipulate the range slider, the yaxis will automatically adjust to the data in view (plus a 10 unit buffer on each side, you should adjust that to what looks right for your data.

Hope that helps!
-Jon

6 Likes

is there any other solution? like change something in rangeslider?

1 Like

Not that I’m aware of.
-Jon

Hi thank you for your solution it exactly what i need it…
However i was unable to implement correctly the code
could someone figure it out what wrong in the last part for this code
Thank

fig = go.FigureWidget(data=data, layout=go.layout)
fig

#Y-axis autoscaling with x-range sliders

def zoom(layout, xrange):
in_view = df.loc[fig.layout.xaxis.range[0]:fig.layout.xaxis.range[1]]
fig.layout.yaxis.range = [in_view.trace_low.min() - 2, in_view.trace_high.max() + 2]

fig.layout.on_change(zoom, ‘xaxis.range’)

fig

plotly.offline.plot(fig, filename = “Time Series Forecast Multivariable (61) MultiStep (3).html”)

Hi @elaliberte,

For the on_change callbacks to be triggered you need to let the FigureWidget display itself (make fig the last statement in a notebook output cell). And these callbacks won’t work on the HTML result of plotly.offline.plot unfortunately because the callbacks require a running Python kernel.

Hope that helps clear things up,
-Jon

我也碰到相同的问题,请问:plotly.offline.plot 应该怎样做呢?
谢谢!

Hello @jmmease
I tried to print something inside the callback function.

def zoom(layout, xrange):
    in_view = df.loc[fig.layout.xaxis.range[0] : fig.layout.xaxis.range[1]]
    fig.layout.yaxis.range = [in_view.High.min() - 10, in_view.High.max() + 10]
    print(in_view.High.max())

but nothing is printed in jupyterlab. The error message isn’t printed either.
however, I tested at jupyter notebook, it works fine.

Is it a problem with jupyterlab extension? If there is any solution, what should I do?

Thank you.

1 Like

Hello @jmmease is it possible that this solution does not work for Jupyter Lab?

Hello everyone, this is the exact solution i need. But i work with plotly js. Is there any similar solution in js ??

1 Like

Could this be used with the figure returned with make_subplots? i have a rather complicated chart with n number of subplots…and have been trying to find a solution to this problem.

fig = make_subplots(rows=len(self.panes), cols=1, shared_xaxes=True,
vertical_spacing=0.020, row_heights=row_height, specs=secondary_axis)

        fig.update_xaxes(showline=True, linewidth=1, linecolor='darkgray', mirror=True, ticks='outside', 
                showspikes=True, spikemode='across', spikesnap='cursor',
                rangebreaks=[dict(values = self._get_missing_dates(self.data))])

        fig.update_yaxes(showline=True, linewidth=1, linecolor='darkgray', mirror=True, ticks='outside', side='right',
                showspikes=True, spikemode='across', spikesnap='cursor')
        
        fig.update_layout(margin=dict(l=20, b=50, t=50, pad=10), font_size=10, 
                        hovermode='x unified',dragmode='zoom', 
                        yaxis2=dict(side='left', overlaying="y",constrain='domain',
                        xaxis_rangeslider_visible=False, xaxis_type="date")) 

Will all the above settings work with FigureWidget? lastly, is it possible to add horizontal lines using figurewidget? horizonal lines don’t seem to be drawn the same way (not treated as a ‘trace’) and is a method on the figure object itself

1 Like

same here…did you find a solution to this?

Is it possible to do this in Dash’s dcc.Graph?

Is there still no good way to do this in the HTML output plot (i.e. not in Jupyter)?

I see there is some way to do this with buttons described here:

b’Relayout method to change the layout att | empet | Plotly’

And there is this closed bug:

Rescale y-axis for graphs with x-axis rangeslider (Python) · Issue #932 · plotly/plotly.py (github.com)

But I cannot figure out how to do this with rangeselector. I do not want to use Dash. Any ideas?

How would you implement this solution with multiple subplot that also share the same x axis ?

i cant get the go.figurewidget() function to wrap around multiple subplots

In case this is useful to anyone, here is one way of creating a range selector that updates y and x axis of subplots using relayout. Probably not the most elegant way but works for me.

relayout

Code:

#!/usr/bin/env python3

import math
from datetime import datetime

import numpy as np
import pandas as pd
import plotly.express as px
import pytz
from dateutil.relativedelta import relativedelta

TIMEZONE = 'Europe/Zurich'
TODAY_TIME = datetime.now().astimezone(pytz.timezone(TIMEZONE))


def build_ranges(dataframe, columns):
    """Build graph ranges based on min/max values."""
    today = TODAY_TIME.strftime('%Y-%m-%d')
    xranges = {
        'All': [dataframe.index[0].strftime('%Y-%m-%d'), today],
        '2y': [(TODAY_TIME + relativedelta(years=-2)).strftime('%Y-%m-%d'),
               today],
        '1y': [(TODAY_TIME + relativedelta(years=-1)).strftime('%Y-%m-%d'),
               today],
        'YTD': [TODAY_TIME.strftime('%Y-01-01'), today],
        '6m': [(TODAY_TIME + relativedelta(months=-6)).strftime('%Y-%m-%d'),
               today],
        '3m': [(TODAY_TIME + relativedelta(months=-3)).strftime('%Y-%m-%d'),
               today],
        '1m': [(TODAY_TIME + relativedelta(months=-1)).strftime('%Y-%m-%d'),
               today],
    }
    ranges = {}
    for column in columns:
        col_dict = {}
        for span, xrange in xranges.items():
            col_dict[span] = {
                'yrange': [
                    dataframe.loc[xrange[0]:xrange[1], column].min(),
                    dataframe.loc[xrange[0]:xrange[1], column].max(),
                ],
                'xrange':
                xrange,
            }
        ranges[column] = col_dict
    return ranges


def add_range_buttons(subplot, dataframe, columns):
    """Add a range selector that updates y axis as well as x."""
    ranges = build_ranges(dataframe, columns)
    num_col = len(columns)
    col_split = list(
        reversed(np.array_split(np.array(columns), math.ceil(num_col / 2))))
    buttons = []
    for label in ('All', '2y', '1y', 'YTD', '6m', '3m', '1m'):
        button_dict = dict(
            label=label,
            method='relayout',
        )
        arg_dict = {}
        col = 0
        for pair in col_split:
            for col_name in pair:
                suffix = ''
                if col == 0:
                    arg_dict['xaxis.range'] = ranges[col_name][label]['xrange']
                else:
                    suffix = f'{col + 1}'
                arg_dict[f'yaxis{suffix}.range'] = ranges[col_name][label][
                    'yrange']
                col += 1
            if len(pair) < 2:
                col += 1
        button_dict['args'] = [arg_dict]
        buttons.append(button_dict)
    subplot.update_layout(updatemenus=[
        dict(
            type='buttons',
            direction='right',
            active=2,  # 1y
            x=0.5,
            y=-0.05,
            buttons=buttons,
        )
    ])
    # Select button 2.
    subplot.plotly_relayout(buttons[2]['args'][0])


def make_subplots(daily_df):
    """Create subplots with range selector."""
    columns = ['var1', 'var2', 'var3', 'var4', 'var5', 'var6']
    section = px.line(daily_df,
                      x=daily_df.index,
                      y=columns,
                      facet_col='variable',
                      facet_col_wrap=2,
                      category_orders={'variable': columns})
    section.update_yaxes(matches=None, title_text='')
    section.update_yaxes(col=2, showticklabels=True)
    section.update_xaxes(title_text='', matches='x', showticklabels=True)
    section.update_traces(showlegend=False)
    add_range_buttons(section, daily_df, columns)
    return section


def downsample_df(dataframe):
    """Downsample data older than 1 week."""
    weekly = dataframe.resample('W').mean()
    daily = dataframe.resample('D').mean()
    weekly_concat = weekly[:daily.iloc[-7].name]
    daily_concat = daily[-7:]
    if weekly_concat.iloc[-1].name == daily.iloc[-7].name:
        daily_concat = daily[-6:]
    return pd.concat([weekly_concat, daily_concat])


def main():
    """Main."""
    all_df = pd.read_csv(
        'web/history_example.csv',
        index_col=0,
        parse_dates=True,
        infer_datetime_format=True).tz_localize('UTC').tz_convert(TIMEZONE)

    daily_df = all_df.resample('D').mean().interpolate()

    subplot = make_subplots(downsample_df(daily_df))
    with open('web/foo.html', 'w', encoding='utf-8') as index_file:
        index_file.write(
            subplot.to_html(full_html=True, include_plotlyjs='cdn'))


if __name__ == '__main__':
    main()