Skip to content

Commit

Permalink
Bug/rangeselector (#287)
Browse files Browse the repository at this point in the history
* ✨ fix for #275

* 🧹 review code

* ✨ new example

* 🔍 reviewing examples

* 🖍️ docs-fix for #275

* 🔍 review

---------

Co-authored-by: Jeroen Van Der Donckt <boebievdd@gmail.com>
  • Loading branch information
jonasvdd and jvdd authored Jan 14, 2024
1 parent 46e629f commit 7612315
Show file tree
Hide file tree
Showing 5 changed files with 204 additions and 8 deletions.
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions examples/dash_apps/04_minimal_cache_overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__,
Expand All @@ -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)),
Expand Down
4 changes: 2 additions & 2 deletions examples/dash_apps/05_cache_overview_subplots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__,
Expand All @@ -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)),
Expand Down
193 changes: 193 additions & 0 deletions examples/dash_apps/06_cache_overview_range_buttons.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 6 additions & 4 deletions plotly_resampler/registering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down

0 comments on commit 7612315

Please sign in to comment.