|
| 1 | +"""Minimal dash app example. |
| 2 | +
|
| 3 | +Click on a button, and see a plotly-resampler graph of an exponential and log curve is |
| 4 | +shown. In addition, another graph is shown below, which is an overview of the main |
| 5 | +graph. This other graph is bidirectionally linked to the main graph; when you |
| 6 | +select a region in the overview graph, the main graph will zoom in on that region and |
| 7 | +vice versa. |
| 8 | +
|
| 9 | +On the left top of the main graph, you can see a range selector. This range selector |
| 10 | +allows to zoom in with a fixed time range. |
| 11 | +
|
| 12 | +Lastly, there is a button present to reset the axes of the main graph. This button |
| 13 | +replaces the default reset axis button as the default button removes the spikes. |
| 14 | +(specifically, the `xaxis.showspikes` and `yaxis.showspikes` are set to False; This is |
| 15 | +most likely a bug in plotly-resampler, but I have not yet found out why). |
| 16 | +
|
| 17 | +This example uses the dash-extensions its ServersideOutput functionality to cache |
| 18 | +the FigureResampler per user/session on the server side. This way, no global figure |
| 19 | +variable is used and shows the best practice of using plotly-resampler within dash-apps. |
| 20 | +
|
| 21 | +""" |
| 22 | + |
| 23 | +import dash |
| 24 | +import numpy as np |
| 25 | +import pandas as pd |
| 26 | +import plotly.graph_objects as go |
| 27 | +from dash import Input, Output, State, callback_context, dcc, html, no_update |
| 28 | +from dash_extensions.enrich import DashProxy, Serverside, ServersideOutputTransform |
| 29 | + |
| 30 | +# The overview figure requires clientside callbacks, whose JavaScript code is located |
| 31 | +# in the assets folder. We need to tell dash where to find this folder. |
| 32 | +from plotly_resampler import ASSETS_FOLDER, FigureResampler |
| 33 | +from plotly_resampler.aggregation import MinMaxLTTB |
| 34 | + |
| 35 | +# -------------------------------- Data and constants --------------------------------- |
| 36 | +# Data that will be used for the plotly-resampler figures |
| 37 | +x = np.arange(2_000_000) |
| 38 | +x_time = pd.date_range("2020-01-01", periods=len(x), freq="1min") |
| 39 | +noisy_sin = (3 + np.sin(x / 200) + np.random.randn(len(x)) / 10) * x / 1_000 |
| 40 | + |
| 41 | +# The ids of the components used in the app (we put them here to avoid typos later on) |
| 42 | +GRAPH_ID = "graph-id" |
| 43 | +OVERVIEW_GRAPH_ID = "overview-graph" |
| 44 | +STORE_ID = "store" |
| 45 | +PLOT_BTN_ID = "plot-button" |
| 46 | + |
| 47 | +# --------------------------------------Globals --------------------------------------- |
| 48 | +# NOTE: Remark how |
| 49 | +# (1) the assets folder is passed to the Dash(proxy) application |
| 50 | +# (2) the lodash script is included as an external script. |
| 51 | +app = DashProxy( |
| 52 | + __name__, |
| 53 | + transforms=[ServersideOutputTransform()], |
| 54 | + assets_folder=ASSETS_FOLDER, |
| 55 | + external_scripts=["https://cdn.jsdelivr.net/npm/lodash/lodash.min.js"], |
| 56 | +) |
| 57 | + |
| 58 | +# Construct the app layout |
| 59 | +app.layout = html.Div( |
| 60 | + [ |
| 61 | + html.H1("plotly-resampler + dash-extensions", style={"textAlign": "center"}), |
| 62 | + html.Button("plot chart", id=PLOT_BTN_ID, n_clicks=0), |
| 63 | + html.Hr(), |
| 64 | + # The graph, overview graph, and serverside store for the FigureResampler graph |
| 65 | + dcc.Graph( |
| 66 | + id=GRAPH_ID, |
| 67 | + # NOTE: we remove the reset scale button as it removes the spikes and |
| 68 | + # we provide our own reset-axis button upon graph construction |
| 69 | + config={"modeBarButtonsToRemove": ["resetscale"]}, |
| 70 | + ), |
| 71 | + dcc.Graph(id=OVERVIEW_GRAPH_ID, config={"displayModeBar": False}), |
| 72 | + dcc.Loading(dcc.Store(id=STORE_ID)), |
| 73 | + ] |
| 74 | +) |
| 75 | + |
| 76 | + |
| 77 | +# ------------------------------------ DASH logic ------------------------------------- |
| 78 | +# --- construct and store the FigureResampler on the serverside --- |
| 79 | +@app.callback( |
| 80 | + [ |
| 81 | + Output(GRAPH_ID, "figure"), |
| 82 | + Output(OVERVIEW_GRAPH_ID, "figure"), |
| 83 | + Output(STORE_ID, "data"), |
| 84 | + ], |
| 85 | + Input(PLOT_BTN_ID, "n_clicks"), |
| 86 | + prevent_initial_call=True, |
| 87 | +) |
| 88 | +def plot_graph(_): |
| 89 | + ctx = callback_context |
| 90 | + if not len(ctx.triggered) or PLOT_BTN_ID not in ctx.triggered[0]["prop_id"]: |
| 91 | + return no_update |
| 92 | + |
| 93 | + # 1. Create the figure and add data |
| 94 | + fig = FigureResampler( |
| 95 | + # fmt: off |
| 96 | + go.Figure(layout=dict( |
| 97 | + # dragmode="pan", |
| 98 | + hovermode="x unified", |
| 99 | + xaxis=dict(rangeselector=dict(buttons=list([ |
| 100 | + dict(count=7, label="1 week", step="day", stepmode="backward"), |
| 101 | + dict(count=1, label="1 month", step="month", stepmode="backward"), |
| 102 | + dict(count=2, label="2 months", step="month", stepmode="backward"), |
| 103 | + dict(count=1, label="1 year", step="year", stepmode="backward"), |
| 104 | + ]))), |
| 105 | + )), |
| 106 | + # fmt: on |
| 107 | + default_downsampler=MinMaxLTTB(parallel=True), |
| 108 | + create_overview=True, |
| 109 | + ) |
| 110 | + |
| 111 | + # Figure construction logic |
| 112 | + log = noisy_sin * 0.9999995**x |
| 113 | + exp = noisy_sin * 1.000002**x |
| 114 | + fig.add_trace(go.Scattergl(name="log"), hf_x=x_time, hf_y=log) |
| 115 | + fig.add_trace(go.Scattergl(name="exp"), hf_x=x_time, hf_y=exp) |
| 116 | + |
| 117 | + fig.update_layout( |
| 118 | + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1) |
| 119 | + ) |
| 120 | + fig.update_layout( |
| 121 | + margin=dict(b=10), |
| 122 | + template="plotly_white", |
| 123 | + height=650, # , hovermode="x unified", |
| 124 | + # https://plotly.com/python/custom-buttons/ |
| 125 | + updatemenus=[ |
| 126 | + dict( |
| 127 | + type="buttons", |
| 128 | + x=0.45, |
| 129 | + xanchor="left", |
| 130 | + y=1.09, |
| 131 | + yanchor="top", |
| 132 | + buttons=[ |
| 133 | + dict( |
| 134 | + label="reset axes", |
| 135 | + method="relayout", |
| 136 | + args=[ |
| 137 | + { |
| 138 | + "xaxis.autorange": True, |
| 139 | + "yaxis.autorange": True, |
| 140 | + "xaxis.showspikes": True, |
| 141 | + "yaxis.showspikes": False, |
| 142 | + } |
| 143 | + ], |
| 144 | + ), |
| 145 | + ], |
| 146 | + ) |
| 147 | + ], |
| 148 | + ) |
| 149 | + # fig.update_traces(xaxis="x") |
| 150 | + # fig.update_xaxes(showspikes=True, spikemode="across", spikesnap="cursor") |
| 151 | + |
| 152 | + coarse_fig = fig._create_overview_figure() |
| 153 | + return fig, coarse_fig, Serverside(fig) |
| 154 | + |
| 155 | + |
| 156 | +# --- Clientside callbacks used to bidirectionally link the overview and main graph --- |
| 157 | +app.clientside_callback( |
| 158 | + dash.ClientsideFunction(namespace="clientside", function_name="main_to_coarse"), |
| 159 | + dash.Output( |
| 160 | + OVERVIEW_GRAPH_ID, "id", allow_duplicate=True |
| 161 | + ), # TODO -> look for clean output |
| 162 | + dash.Input(GRAPH_ID, "relayoutData"), |
| 163 | + [dash.State(OVERVIEW_GRAPH_ID, "id"), dash.State(GRAPH_ID, "id")], |
| 164 | + prevent_initial_call=True, |
| 165 | +) |
| 166 | + |
| 167 | +app.clientside_callback( |
| 168 | + dash.ClientsideFunction(namespace="clientside", function_name="coarse_to_main"), |
| 169 | + dash.Output(GRAPH_ID, "id", allow_duplicate=True), |
| 170 | + dash.Input(OVERVIEW_GRAPH_ID, "selectedData"), |
| 171 | + [dash.State(GRAPH_ID, "id"), dash.State(OVERVIEW_GRAPH_ID, "id")], |
| 172 | + prevent_initial_call=True, |
| 173 | +) |
| 174 | + |
| 175 | + |
| 176 | +# --- FigureResampler update callback --- |
| 177 | +# The plotly-resampler callback to update the graph after a relayout event (= zoom/pan) |
| 178 | +# As we use the figure again as output, we need to set: allow_duplicate=True |
| 179 | +@app.callback( |
| 180 | + Output(GRAPH_ID, "figure", allow_duplicate=True), |
| 181 | + Input(GRAPH_ID, "relayoutData"), |
| 182 | + State(STORE_ID, "data"), # The server side cached FigureResampler per session |
| 183 | + prevent_initial_call=True, |
| 184 | +) |
| 185 | +def update_fig(relayoutdata, fig: FigureResampler): |
| 186 | + if fig is None: |
| 187 | + return no_update |
| 188 | + return fig.construct_update_data_patch(relayoutdata) |
| 189 | + |
| 190 | + |
| 191 | +if __name__ == "__main__": |
| 192 | + # Start the app |
| 193 | + app.run(debug=True, host="localhost", port=8055, use_reloader=False) |
0 commit comments