predict-idlab / plotly-resampler

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

dynamically add/remove annotation/shape break the resampler #178

Closed Jeremy38100 closed 11 months ago

Jeremy38100 commented 1 year ago

Hello,

I implemented a system to add an anotation / shape when the user clicks on the chart and it's dynamically adding / removing a plotly annotations / shape (I'm using dash). I use the fig.add_annotation method for instance.

The issue is when the user is zoomed in the chart, the add_annotation method reset the sampling as it is at top zoom level and i have to manually click on zoom then unzoom to get the current sampling when the user clicked on the chart

jvdd commented 1 year ago

Hey @Jeremy38100,

Thank you for submitting this issue!

Can you provide a minimal reproducible example? I'll gladly have a look at it :)

Cheers, Jeroen

Jeremy38100 commented 1 year ago

Sure :

import numpy as np
import plotly.graph_objects as go
from dash import Dash, Input, Output, callback_context, dcc, html, no_update
from trace_updater import TraceUpdater

from plotly_resampler import FigureResampler

x = np.arange(2_000_000)
noisy_sin = (3 + np.sin(x / 200) + np.random.randn(len(x)) / 10) * x / 1_000

app = Dash(__name__)
fig: FigureResampler = FigureResampler()

app.layout = html.Div(
    [
        html.H1("plotly-resampler global variable", style={"textAlign": "center"}),
        html.Button("plot chart", id="plot-button", n_clicks=0),
        html.Button("add Annotation", id="plot-annotation", n_clicks=0),
        html.Hr(),
        dcc.Graph(id="graph-id"),
        TraceUpdater(id="trace-updater", gdID="graph-id"),
    ]
)

@app.callback(
    Output("graph-id", "figure"),
    Input("plot-button", "n_clicks"),
    Input("plot-annotation", "n_clicks"),
    prevent_initial_call=True,
)
def plot_graph(n_clicks, n_clicks_annotations):
    ctx = callback_context
    if len(ctx.triggered) and "plot-button" in ctx.triggered[0]["prop_id"]:
        global fig
        fig.replace(go.Figure())
        fig.add_trace(go.Scattergl(name="log"), hf_x=x, hf_y=noisy_sin * 0.9999995**x)
        fig.add_trace(go.Scattergl(name="exp"), hf_x=x, hf_y=noisy_sin * 1.000002**x)
        return fig
    if len(ctx.triggered) and "plot-annotation" in ctx.triggered[0]["prop_id"]:
        x_annotation = 1.5*1000*1000
        fig = fig.add_annotation(x=x_annotation, y=80*1000,
            text="Text annotation with arrow",
            showarrow=True,
            arrowhead=1)
        return fig
    else:
        return no_update

fig.register_update_graph_callback(
    app=app, graph_id="graph-id", trace_updater_id="trace-updater"
)

if __name__ == "__main__":
    app.run_server(debug=True, port=9023)

Zoom closely arour 1.5M then click add annotation, it resets zoom level.

jvdd commented 1 year ago

Thx for providing the code snippet, I was able to reproduce the issue.

The underlying problem is that when outputting a "new" figure in the callback - the client-side figure is updated (with the current view being resetted). To circumvent this issue we can trigger the resampling by letting the callback take relayoutData as input and then manually update the graph - I updated the example for this :arrow_down:

@app.callback(
    Output("graph-id", "figure"),
    Input("graph-id", "relayoutData"),
    Input("plot-button", "n_clicks"),
    Input("plot-annotation", "n_clicks"),
    prevent_initial_call=True,
)
def plot_graph(relayout_data, n_clicks, n_clicks_annotations):
    ctx = callback_context
    global fig
    if len(ctx.triggered) and "plot-button" in ctx.triggered[0]["prop_id"]:
        fig.replace(go.Figure())
        fig.add_trace(go.Scattergl(name="log"), hf_x=x, hf_y=noisy_sin * 0.9999995**x)
        fig.add_trace(go.Scattergl(name="exp"), hf_x=x, hf_y=noisy_sin * 1.000002**x)
        return fig
    if len(ctx.triggered) and "plot-annotation" in ctx.triggered[0]["prop_id"]:
        x_annotation = 1.5*1000*1000
        fig.add_annotation(x=x_annotation, y=80*1000,
            text="Text annotation with arrow",
            showarrow=True,
            arrowhead=1)
        ## Added the code below to update the graph for the current view
        if relayout_data:
            update_data = fig.construct_update_data(relayout_data)
            if not fig._is_no_update(update_data):  # when there is an update
                with fig.batch_update():
                    # First update the layout (first item of update_data)
                    fig.layout.update(update_data[0])

                    # Then update the data
                    for updated_trace in update_data[1:]:
                        trace_idx = updated_trace.pop("index")
                        fig.data[trace_idx].update(updated_trace)
        return fig
    else:
        return no_update

My 5 cents regarding this hacky fix:

I'm interested in hearing what you think of this @Jeremy38100

jonasvdd commented 11 months ago

Will close this issue for now as we did not hear back from you @Jeremy38100