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..fe4b6934 --- /dev/null +++ b/examples/dash_apps/06_cache_overview_range_buttons.py @@ -0,0 +1,190 @@ +"""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) +GRAPH_ID = "graph-id" +OVERVIEW_GRAPH_ID = "overview-graph" +STORE_ID = "store" +PLOT_BTN_ID = "plot-button" + +# --------------------------------------Globals --------------------------------------- +# 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__, + transforms=[ServersideOutputTransform()], + assets_folder=ASSETS_FOLDER, + external_scripts=["https://cdn.jsdelivr.net/npm/lodash/lodash.min.js"], +) + +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, + 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(_): + global app + 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/__init__.py b/plotly_resampler/__init__.py index a53fe0f5..299c98ed 100644 --- a/plotly_resampler/__init__.py +++ b/plotly_resampler/__init__.py @@ -2,7 +2,7 @@ import contextlib -from .aggregation import LTTB, EveryNthPoint, MinMaxLTTB +from .aggregation import LTTB, M4, EveryNthPoint, MinMaxLTTB from .figure_resampler import ASSETS_FOLDER, FigureResampler, FigureWidgetResampler from .registering import register_plotly_resampler, unregister_plotly_resampler @@ -17,6 +17,7 @@ "ASSETS_FOLDER", "MinMaxLTTB", "LTTB", + "M4", "EveryNthPoint", "register_plotly_resampler", "unregister_plotly_resampler", diff --git a/plotly_resampler/aggregation/__init__.py b/plotly_resampler/aggregation/__init__.py index 428beac7..0db6598a 100644 --- a/plotly_resampler/aggregation/__init__.py +++ b/plotly_resampler/aggregation/__init__.py @@ -10,6 +10,7 @@ from .aggregation_interface import AbstractAggregator from .aggregators import ( LTTB, + M4, EveryNthPoint, FuncAggregator, MinMaxAggregator, @@ -25,6 +26,7 @@ "AbstractGapHandler", "PlotlyAggregatorParser", "LTTB", + "M4", "MinMaxLTTB", "EveryNthPoint", "FuncAggregator", diff --git a/plotly_resampler/aggregation/aggregators.py b/plotly_resampler/aggregation/aggregators.py index e7891370..29a7299e 100644 --- a/plotly_resampler/aggregation/aggregators.py +++ b/plotly_resampler/aggregation/aggregators.py @@ -15,6 +15,7 @@ from tsdownsample import ( EveryNthDownsampler, LTTBDownsampler, + M4Downsampler, MinMaxDownsampler, MinMaxLTTBDownsampler, ) @@ -195,6 +196,33 @@ def _arg_downsample( ) +class M4(DataPointSelector): + """M4 aggregation method.""" + + def __init__(self, **downsample_kwargs): + """ + Parameters + ---------- + **downsample_kwargs + Keyword arguments passed to the :class:`M4Downsampler`. + - The `parallel` argument is set to False by default. + + """ + # this downsampler supports all dtypes + super().__init__(**downsample_kwargs) + self.downsampler = M4Downsampler() + + def _arg_downsample( + self, + x: np.ndarray | None, + y: np.ndarray, + n_out: int, + ) -> np.ndarray: + return self.downsampler.downsample( + *_to_tsdownsample_args(x, y), n_out=n_out, **self.downsample_kwargs + ) + + class MinMaxLTTB(DataPointSelector): """Efficient version off LTTB by first reducing really large datasets with the [`MinMaxAggregator`][aggregation.aggregators.MinMaxAggregator] and then further aggregating the diff --git a/plotly_resampler/figure_resampler/figure_resampler_interface.py b/plotly_resampler/figure_resampler/figure_resampler_interface.py index 13a304ff..f6cf3908 100644 --- a/plotly_resampler/figure_resampler/figure_resampler_interface.py +++ b/plotly_resampler/figure_resampler/figure_resampler_interface.py @@ -43,7 +43,7 @@ class AbstractFigureAggregator(BaseFigure, ABC): """Abstract interface for data aggregation functionality for plotly figures.""" - _high_frequency_traces = ["scatter", "scattergl"] + _high_frequency_traces = ["scatter", "scattergl", "candlestick"] def __init__( self, @@ -342,8 +342,15 @@ def _check_update_trace_data( # Also check if the y-data is empty, if so, return an empty trace if len(hf_trace_data["y"]) == 0: trace["x"] = [] - trace["y"] = [] trace["name"] = hf_trace_data["name"] + if trace["type"] == "candlestick": + trace["open"] = [] + trace["high"] = [] + trace["low"] = [] + trace["close"] = [] + trace["x"] = [] + else: + trace["y"] = [] return trace # Leverage the axis type to get the start and end indices @@ -362,8 +369,14 @@ def _check_update_trace_data( # contain any data in the current view if end_idx == start_idx: trace["x"] = [hf_trace_data["x"][0]] - trace["y"] = [None] trace["name"] = hf_trace_data["name"] + if trace["type"] == "candlestick": + trace["open"] = None + trace["high"] = None + trace["low"] = None + trace["close"] = None + else: + trace["y"] = [None] return trace agg_x, agg_y, indices = PlotlyAggregatorParser.aggregate( @@ -371,13 +384,57 @@ def _check_update_trace_data( ) # -------------------- Set the hf_trace_data_props ------------------- + trace["name"] = self._parse_trace_name( + hf_trace_data, end_idx - start_idx, agg_x + ) + if trace["type"] == "candlestick": + # TODO -> maybe abstract this in a per-trace type function + # we slice the candlestick data based on the indices + y_se = hf_trace_data["y"][start_idx:end_idx] + x_se = hf_trace_data["x"][start_idx:end_idx] + + idx_open = indices[::4] + idx_close = indices[3::4] + + # NOTE: as for now the extremum argmin and max indices are sorted within M4 + # so we need extra logic to separate them into minima and maxima indices + extrema_1 = indices[1::4] + extrema_2 = indices[2::4] + + # this matters when the number of candlesticks is not a multiple of 4 + # which can happen when the user zooms in really far + # (and the number of datapoints is small) + min_len = min(len(extrema_1), len(extrema_2), len(idx_open), len(idx_close)) + extrema_1 = extrema_1[:min_len] + extrema_2 = extrema_2[:min_len] + idx_open = idx_open[:min_len] + idx_close = idx_close[:min_len] + + # Add the extrema indices in an array of [2, n_candlesticks] + # then compute the arg-max along the first axis (output = [n_candlesticks]) + extrema_idxs = np.array([extrema_1, extrema_2]) + a_max_indices = np.argmax( + np.array([y_se[extrema_1], y_se[extrema_2]]), axis=0 + ) + # via 'take_along_axis', the max / min indices are separated into two arrays + idxs_high = np.take_along_axis( + extrema_idxs, a_max_indices[None, :], axis=0 + ).ravel() + idxs_low = np.take_along_axis( + extrema_idxs, (1 - a_max_indices)[None, :], axis=0 + ).ravel() + + trace["open"] = y_se[idx_open] + trace["high"] = y_se[idxs_high] + trace["low"] = y_se[idxs_low] + trace["close"] = y_se[idx_close] + trace["x"] = x_se[idx_open] + return trace + # Parse the data types to an orjson compatible format # NOTE: this can be removed once orjson supports f16 trace["x"] = self._parse_dtype_orjson(agg_x) trace["y"] = self._parse_dtype_orjson(agg_y) - trace["name"] = self._parse_trace_name( - hf_trace_data, end_idx - start_idx, agg_x - ) def _nest_dict_rec(k: str, v: any, out: dict) -> None: """Recursively nest a dict based on the key whose '_' indicates level.""" @@ -1430,7 +1487,11 @@ def _construct_update_data( layout_traces_list: List[dict] = [relayout_data] # 2. Create the additional trace data for the frond-end - relevant_keys = list(_hf_data_container._fields) + ["name", "marker"] + relevant_keys = ( + list(_hf_data_container._fields) + + ["name", "marker"] + + ["open", "high", "low", "close"] + ) # Note that only updated trace-data will be sent to the client for idx in updated_trace_indices: trace = current_graph["data"][idx] 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 ---------- diff --git a/pyproject.toml b/pyproject.toml index 94c1bd9f..b44b4a2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "plotly-resampler" # Do not forget to update the __init__.py __version__ variable -version = "0.9.2rc3" +version = "0.9.2" description = "Visualizing large time series with plotly" authors = ["Jonas Van Der Donckt", "Jeroen Van Der Donckt", "Emiel Deprost"] readme = "README.md"