predict-idlab / plotly-resampler

Visualize large time series data with plotly.py
https://predict-idlab.github.io/plotly-resampler/latest
MIT License
990 stars 67 forks source link

Zooming in does not resample when Y-value are identical #275

Open Joakimden4 opened 7 months ago

Joakimden4 commented 7 months ago

Hi! First off, thanks a lot for your incredible work on this much-needed resampling functionality! Also, I'm quite inexperienced with submitting github issues, so please let me know if you need any additional information.

I am trying to apply resampling in a plotly dash app on a go.Scattergl trace that visualizes a time series of around 200k data points with hourly frequency. The app works fine, but when I zoom in on the graph, the granularity of the data remains very low (e.g. weekly granularity). I suspect it is because the Y-values in my dataset are identical over many subsequent hours, thus in theory not requiring resampling. However, I would like the user to be able to hover and click the data points, as this graph is part of a bigger interactive app, which initializes different visuals when the user clicks the data points in the graph.

So in essence I have two questions: 1) Can you confirm that the missing resampling when zooming in is due to the Y-values being identical over many subsequent hours? 2) If yes, is there a way to force the resampling, so that the graph shows interactable data points on a higher granularity despite identical Y-values?

Below is the code pertaining to the graph in question within the app. I have also attached my dataset here data.csv

register_plotly_resampler(mode='auto')

layout = dcc.Graph(
    id='graph_bo_hourly',
    clear_on_unhover=True,
)

df_hourly = pd.read_csv("data.csv", index_col=0)

hourly_fig = go.Figure(
    layout=dict(
        dragmode='pan',
        hovermode='x unified',
        xaxis=dict(
            rangeslider_visible=True,
            rangeselector=dict(
                buttons=list([
                    dict(count=1, label="1 day", step="day", stepmode="backward"),
                    dict(count=1, label="1 month", step="month", stepmode="backward"),
                    dict(count=1, label="1 year", step="year", stepmode="backward"),
                    dict(step="all")
                ])
            ),
        ),
    ),
)
hourly_fig.add_trace(go.Scattergl(x=df_hourly['Date'], y=df_hourly['MWh'], name='Hourly position', mode='lines'))

Thanks in advance!

jonasvdd commented 6 months ago

Hi @Joakimden4,

Can you verify whether the functionality (and demo posted on #286 resolves your issue)?

Joakimden4 commented 6 months ago

Hi @jonasvdd,

The provided example works when it is not combined with dash components. However, when I wrap the figure in a dcc.Graph component and display it in a dash, it behaves the exact same way as originally reported in this bug.

Am I implementing it wrongly within the dash framework? I ran this code on the bug/rangeselector branch using the dependencies specified by you:

import dash
from dash import html, dcc
from plotly_resampler import register_plotly_resampler
import plotly.graph_objects as go
import pandas as pd

register_plotly_resampler(mode="figure", create_overview=True, verbose=True)

df_hourly = pd.read_csv("data.csv", index_col=0)

hourly_fig = go.Figure(
    layout=dict(
        dragmode='pan',
        hovermode='x unified',
        xaxis=dict(
            rangeslider_visible=True,
            rangeselector=dict(
                buttons=list([
                    dict(count=1, label="1 day", step="day", stepmode="backward"),
                    dict(count=1, label="1 month", step="month", stepmode="backward"),
                    dict(count=1, label="1 year", step="year", stepmode="backward"),
                    dict(step="all")
                ])
            ),
        ),
    ),
)
hourly_fig.add_trace(go.Scattergl(x=df_hourly['Date'], y=df_hourly['MWh'], name='Hourly position', mode='lines'))

app = dash.Dash(__name__, meta_tags=[{'name':'viewport', 'content':'width=device-width, initial-scale=1.0'}])
app.layout = html.Div(
    dcc.Graph(
        id='graph_bo_hourly',
        figure=hourly_fig
    )
)

app.run(debug=False, host='localhost')
jonasvdd commented 6 months ago

Hi @Joakimden4,

When you use plotly-resampler from the main-branch, the dash app example below appears to work: What did I change / how did I make this:

import dash
from dash import html, dcc, Input, Output, State, no_update
import plotly.graph_objects as go
import pandas as pd

# For plain dash apps you need to use the FigureResampler class
# (the register function is for notebooks only)
from plotly_resampler import FigureResampler, ASSETS_FOLDER

FigureResampler(create_overview=True, verbose=True)
GRAPH_ID = "graph-id"
OVERVIEW_GRAPH_ID = "overview-graph"

# 0. Load the data
df_hourly = pd.read_csv("data.csv", index_col=0)

# 1. Create the figure and add data
# fmt: off
hourly_fig = FigureResampler(
    go.Figure(
        layout=dict(
            dragmode="pan",
            hovermode="x unified",
            xaxis=dict(
                rangeselector=dict(
                    buttons=list( [
                        dict(count=1, label="1 day", step="day", stepmode="backward"),
                        dict(count=1, label="1 month", step="month", stepmode="backward"),
                        dict(count=1, label="1 year", step="year", stepmode="backward",),
                    ])
                ),
            ),
        )
    ),
)
hourly_fig.add_trace(go.Scattergl(x=df_hourly["Date"], y=df_hourly["MWh"], name="Hourly position", mode="lines"))

# 1.1 Create the overview figure
coarse_fig = hourly_fig._create_overview_figure()

# Create the app in which the figure will be displayed
app = dash.Dash(
    __name__,
    meta_tags=[
        {"name": "viewport", "content": "width=device-width, initial-scale=1.0"}
    ],
    assets_folder=ASSETS_FOLDER,
    external_scripts=["https://cdn.jsdelivr.net/npm/lodash/lodash.min.js"],
)
# NOTE: you need to create both a coars and 
app.layout = html.Div(
    children=[
        dcc.Graph(id=GRAPH_ID, figure=hourly_fig),
        dcc.Graph(id=OVERVIEW_GRAPH_ID, figure=coarse_fig),
    ]
)

# -------------------- Callbacks --------------------
# --- Clientside callbacks used to bidirectionally link the overview and main graph ---
app.clientside_callback(
    dash.ClientsideFunction(namespace="clientside", function_name="main_to_coarse"),
    dash.Output(OVERVIEW_GRAPH_ID, "id", allow_duplicate=True),
    dash.Input(GRAPH_ID, "relayoutData"),
    [dash.State(OVERVIEW_GRAPH_ID, "id"), dash.State(GRAPH_ID, "id")],
    prevent_initial_call=True,
)

app.clientside_callback(
    dash.ClientsideFunction(namespace="clientside", function_name="coarse_to_main"),
    dash.Output(GRAPH_ID, "id", allow_duplicate=True),
    dash.Input(OVERVIEW_GRAPH_ID, "selectedData"),
    [dash.State(GRAPH_ID, "id"), dash.State(OVERVIEW_GRAPH_ID, "id")],
    prevent_initial_call=True,
)

# --- FigureResampler update callback ---
# The plotly-resampler callback to update the graph after a relayout event (= zoom/pan)
# As we use the figure again as output, we need to set: allow_duplicate=True
@app.callback(
    Output(GRAPH_ID, "figure", allow_duplicate=True),
    Input(GRAPH_ID, "relayoutData"),
    prevent_initial_call=True,
)
def update_fig(relayoutdata: dict):
    if relayoutdata is None:
        return no_update
    return hourly_fig.construct_update_data_patch(relayoutdata)

# Start the app
app.run(debug=False, host="localhost")

I hope this helps you further. Kind regards, Jonas

Joakimden4 commented 6 months ago

Hi @jonasvdd, Thank you very much for the provided example. I'd love to try this out in my actual project that uses plotly-resampler as dependency. When are you planning on releasing this?

jonasvdd commented 6 months ago

Hi @Joakimden4,

I plan to release a new version somewhere this week. ( I just want to improve the docs of the register_plotly_resampler function)

Kind regards, Jonas

Joakimden4 commented 6 months ago

Hi @jonasvdd,

That's awesome, thanks a lot for your hard work! :)

jonasvdd commented 6 months ago

Version 0.9.2 was released!

Please let me know whether your code works, and if so, you can close this issue! :)

Joakimden4 commented 6 months ago

Hi @jonasvdd, I've updated to version 0.9.2, but I'm still not able to get it to work. The issue is that no matter what I try to pass to the construct_update_data_patch method, it returns a dash.no_update event. It always executes line 1333 of figure_resampler_interface.py.

My use case is to pass the figure attribute of the dcc.Graph element through a callback and then apply the resampling. The reason for this is that the user can change the data for the figure during runtime of the application, so I cannot set a static figure. However, even if I try rebuilding the figure from scratch within the callback to avoid potential dash callback passing format issues, I still end up with the dash.no_update event.

I've tried with a super simple use case where there is no coarse graph. If you have any ideas how to solve this it would be much appreciated!

@callback(
    Output('graph_bo_hourly', 'figure', allow_duplicate=True),
    Input('graph_bo_hourly', 'relayoutData'),
    State('graph_bo_hourly', 'figure'),
    prevent_initial_call=True
)
def resample_fig(relayoutdata, fig_state):
    if relayoutdata is None:
        return dash.no_update
    else:
        hourly_fig = FigureResampler(
            go.Figure(
                layout=dict(
                    dragmode='pan',
                    hovermode='x unified',
                    xaxis=dict(
                        rangeselector=dict(
                            buttons=list([
                                dict(count=1, label="1 day", step="day", stepmode="backward"),
                                dict(count=1, label="1 month", step="month", stepmode="backward"),
                                dict(count=1, label="1 year", step="year", stepmode="backward"),
                            ])
                        ),
                    ),
                ),
            )
        )
        x_dates = [datetime.strptime(date, "%Y-%m-%dT%H:%M:%S") for date in fig_state['data'][0]['x']]
        hourly_fig.add_trace(go.Scattergl(x=x_dates, y=fig_state['data'][0]['y'], name='Hourly position', mode='lines'))
        return hourly_fig.construct_update_data_patch(relayoutdata)
Joakimden4 commented 6 months ago

Hi @jonasvdd, I just realized that I'm continually trying to resample the downsampled dataset, which explains why it's not working. I will have to rethink my whole approach.

I'll let you know when I've verified whether it's working, once I've fixed my approach.