diff --git a/examples/README.md b/examples/README.md index 325bf0d4..058ae6a1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -46,6 +46,7 @@ The [dash_apps](dash_apps/) folder contains example dash apps in which `plotly-r | [runtime graph construction](dash_apps/03_minimal_cache_dynamic.py) | minimal example where graphs are constructed based on user interactions at runtime. [Pattern matching callbacks](https://dash.plotly.com/pattern-matching-callbacks) are used construct these plotly-resampler graphs dynamically. Again, server side caching is performed. | | [xaxis overview (rangeslider)](dash_apps/04_minimal_cache_overview.py) | minimal example where a linked xaxis overview is shown below the `FigureResampler` figure. This xaxis rangeslider utilizes [clientside callbacks](https://dash.plotly.com/clientside-callbacks) to realize this behavior. | | [xaxis overview (subplots)](dash_apps/05_cache_overview_subplots.py) | example where a linked xaxis overview is shown below the `FigureResampler` figure (with subplots). | +| [overview range selector button](dash_apps/06_cache_overview_range_buttons.py) | example where (i) a linked xaxis overview is shown below the `FigureResampler` figure, and (ii) a rangeselector along with a reset axis button is utilized to zoom in on specific window sizes. | | **advanced apps** | | | [dynamic sine generator](dash_apps/11_sine_generator.py) | exponential sine generator which uses [pattern matching callbacks](https://dash.plotly.com/pattern-matching-callbacks) to remove and construct plotly-resampler graphs dynamically | | [file visualization](dash_apps/12_file_selector.py) | load and visualize multiple `.parquet` files with plotly-resampler | diff --git a/examples/dash_apps/04_minimal_cache_overview.py b/examples/dash_apps/04_minimal_cache_overview.py index 468010ef..ac80c6b6 100644 --- a/examples/dash_apps/04_minimal_cache_overview.py +++ b/examples/dash_apps/04_minimal_cache_overview.py @@ -33,7 +33,7 @@ # --------------------------------------Globals --------------------------------------- -# NOTE: Remark how the assests folder is passed to the Dash(proxy) application and how +# NOTE: Remark how the assets folder is passed to the Dash(proxy) application and how # the lodash script is included as an external script. app = DashProxy( __name__, @@ -47,7 +47,7 @@ html.H1("plotly-resampler + dash-extensions", style={"textAlign": "center"}), html.Button("plot chart", id="plot-button", n_clicks=0), html.Hr(), - # The graph, overview graph, and servside store for the FigureResampler graph + # The graph, overview graph, and serverside store for the FigureResampler graph dcc.Graph(id=GRAPH_ID), dcc.Graph(id=OVERVIEW_GRAPH_ID), dcc.Loading(dcc.Store(id=STORE_ID)), diff --git a/examples/dash_apps/05_cache_overview_subplots.py b/examples/dash_apps/05_cache_overview_subplots.py index b212c56e..bf6351c2 100644 --- a/examples/dash_apps/05_cache_overview_subplots.py +++ b/examples/dash_apps/05_cache_overview_subplots.py @@ -37,7 +37,7 @@ # --------------------------------------Globals --------------------------------------- -# NOTE: Remark how the assests folder is passed to the Dash(proxy) application and how +# NOTE: Remark how the assets folder is passed to the Dash(proxy) application and how # the lodash script is included as an external script. app = DashProxy( __name__, @@ -51,7 +51,7 @@ html.H1("plotly-resampler + dash-extensions", style={"textAlign": "center"}), html.Button("plot chart", id="plot-button", n_clicks=0), html.Hr(), - # The graph, overview graph, and servside store for the FigureResampler graph + # The graph, overview graph, and serverside store for the FigureResampler graph dcc.Graph(id=GRAPH_ID), dcc.Graph(id=OVERVIEW_GRAPH_ID), dcc.Loading(dcc.Store(id=STORE_ID)), diff --git a/examples/dash_apps/06_cache_overview_range_buttons.py b/examples/dash_apps/06_cache_overview_range_buttons.py new file mode 100644 index 00000000..fd78d4e3 --- /dev/null +++ b/examples/dash_apps/06_cache_overview_range_buttons.py @@ -0,0 +1,193 @@ +"""Minimal dash app example. + +Click on a button, and see a plotly-resampler graph of an exponential and log curve is +shown. In addition, another graph is shown below, which is an overview of the main +graph. This other graph is bidirectionally linked to the main graph; when you +select a region in the overview graph, the main graph will zoom in on that region and +vice versa. + +On the left top of the main graph, you can see a range selector. This range selector +allows to zoom in with a fixed time range. + +Lastly, there is a button present to reset the axes of the main graph. This button +replaces the default reset axis button as the default button removes the spikes. +(specifically, the `xaxis.showspikes` and `yaxis.showspikes` are set to False; This is +most likely a bug in plotly-resampler, but I have not yet found out why). + +This example uses the dash-extensions its ServersideOutput functionality to cache +the FigureResampler per user/session on the server side. This way, no global figure +variable is used and shows the best practice of using plotly-resampler within dash-apps. + +""" + +import dash +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from dash import Input, Output, State, callback_context, dcc, html, no_update +from dash_extensions.enrich import DashProxy, Serverside, ServersideOutputTransform + +# The overview figure requires clientside callbacks, whose JavaScript code is located +# in the assets folder. We need to tell dash where to find this folder. +from plotly_resampler import ASSETS_FOLDER, FigureResampler +from plotly_resampler.aggregation import MinMaxLTTB + +# -------------------------------- Data and constants --------------------------------- +# Data that will be used for the plotly-resampler figures +x = np.arange(2_000_000) +x_time = pd.date_range("2020-01-01", periods=len(x), freq="1min") +noisy_sin = (3 + np.sin(x / 200) + np.random.randn(len(x)) / 10) * x / 1_000 + +# The ids of the components used in the app (we put them here to avoid typos later on) +GRAPH_ID = "graph-id" +OVERVIEW_GRAPH_ID = "overview-graph" +STORE_ID = "store" +PLOT_BTN_ID = "plot-button" + +# --------------------------------------Globals --------------------------------------- +# NOTE: Remark how +# (1) the assets folder is passed to the Dash(proxy) application +# (2) the lodash script is included as an external script. +app = DashProxy( + __name__, + transforms=[ServersideOutputTransform()], + assets_folder=ASSETS_FOLDER, + external_scripts=["https://cdn.jsdelivr.net/npm/lodash/lodash.min.js"], +) + +# Construct the app layout +app.layout = html.Div( + [ + html.H1("plotly-resampler + dash-extensions", style={"textAlign": "center"}), + html.Button("plot chart", id=PLOT_BTN_ID, n_clicks=0), + html.Hr(), + # The graph, overview graph, and serverside store for the FigureResampler graph + dcc.Graph( + id=GRAPH_ID, + # NOTE: we remove the reset scale button as it removes the spikes and + # we provide our own reset-axis button upon graph construction + config={"modeBarButtonsToRemove": ["resetscale"]}, + ), + dcc.Graph(id=OVERVIEW_GRAPH_ID, config={"displayModeBar": False}), + dcc.Loading(dcc.Store(id=STORE_ID)), + ] +) + + +# ------------------------------------ DASH logic ------------------------------------- +# --- construct and store the FigureResampler on the serverside --- +@app.callback( + [ + Output(GRAPH_ID, "figure"), + Output(OVERVIEW_GRAPH_ID, "figure"), + Output(STORE_ID, "data"), + ], + Input(PLOT_BTN_ID, "n_clicks"), + prevent_initial_call=True, +) +def plot_graph(_): + ctx = callback_context + if not len(ctx.triggered) or PLOT_BTN_ID not in ctx.triggered[0]["prop_id"]: + return no_update + + # 1. Create the figure and add data + fig = FigureResampler( + # fmt: off + go.Figure(layout=dict( + # dragmode="pan", + hovermode="x unified", + xaxis=dict(rangeselector=dict(buttons=list([ + dict(count=7, label="1 week", step="day", stepmode="backward"), + dict(count=1, label="1 month", step="month", stepmode="backward"), + dict(count=2, label="2 months", step="month", stepmode="backward"), + dict(count=1, label="1 year", step="year", stepmode="backward"), + ]))), + )), + # fmt: on + default_downsampler=MinMaxLTTB(parallel=True), + create_overview=True, + ) + + # Figure construction logic + log = noisy_sin * 0.9999995**x + exp = noisy_sin * 1.000002**x + fig.add_trace(go.Scattergl(name="log"), hf_x=x_time, hf_y=log) + fig.add_trace(go.Scattergl(name="exp"), hf_x=x_time, hf_y=exp) + + fig.update_layout( + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1) + ) + fig.update_layout( + margin=dict(b=10), + template="plotly_white", + height=650, # , hovermode="x unified", + # https://plotly.com/python/custom-buttons/ + updatemenus=[ + dict( + type="buttons", + x=0.45, + xanchor="left", + y=1.09, + yanchor="top", + buttons=[ + dict( + label="reset axes", + method="relayout", + args=[ + { + "xaxis.autorange": True, + "yaxis.autorange": True, + "xaxis.showspikes": True, + "yaxis.showspikes": False, + } + ], + ), + ], + ) + ], + ) + # fig.update_traces(xaxis="x") + # fig.update_xaxes(showspikes=True, spikemode="across", spikesnap="cursor") + + coarse_fig = fig._create_overview_figure() + return fig, coarse_fig, Serverside(fig) + + +# --- 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 + ), # TODO -> look for clean output + 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"), + State(STORE_ID, "data"), # The server side cached FigureResampler per session + prevent_initial_call=True, +) +def update_fig(relayoutdata, fig: FigureResampler): + if fig is None: + return no_update + return fig.construct_update_data_patch(relayoutdata) + + +if __name__ == "__main__": + # Start the app + app.run(debug=True, host="localhost", port=8055, use_reloader=False) diff --git a/plotly_resampler/registering.py b/plotly_resampler/registering.py index f760ab4f..36536ca4 100644 --- a/plotly_resampler/registering.py +++ b/plotly_resampler/registering.py @@ -88,10 +88,12 @@ def register_plotly_resampler(mode="auto", **aggregator_kwargs): We advise to use mode= ``widget`` when working in an IPython based environment as this will just behave as a ``go.FigureWidget``, but with dynamic aggregation. When using mode= ``auto`` or ``figure``; most figures will be wrapped as - [`FigureResampler`][figure_resampler.FigureResampler], - on which - [`show_dash`][figure_resampler.FigureResampler.show_dash] - needs to be called. + [`FigureResampler`][figure_resampler.FigureResampler], on which + [`show_dash`][figure_resampler.FigureResampler.show_dash] needs to be called. + + !!! note + This function is mostly useful for notebooks. For dash-apps, we advise to look + at the dash app examples on [GitHub](https://github.com/predict-idlab/plotly-resampler/tree/main/examples#2-dash-apps) Parameters ----------