From d65e4cf634862aeea8acb85e9d81cbac79533a34 Mon Sep 17 00:00:00 2001 From: jonasvdd Date: Sat, 6 Jan 2024 19:21:00 +0100 Subject: [PATCH 01/11] :sparkles: fix for #275 --- .../figure_resampler/assets/coarse_fine.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/plotly_resampler/figure_resampler/assets/coarse_fine.js b/plotly_resampler/figure_resampler/assets/coarse_fine.js index 3d850f3b..3b55de70 100644 --- a/plotly_resampler/figure_resampler/assets/coarse_fine.js +++ b/plotly_resampler/figure_resampler/assets/coarse_fine.js @@ -90,7 +90,7 @@ window.dash_clientside = Object.assign({}, window.dash_clientside, { const layout_axis_anchors = getLayoutAxisAnchors(main_graphDiv.layout); // Use the maingraphDiv its layout to obtain a list of a list of all shared (x)axis names - // in practice, these are the xaxis names that are linked to each other (i.e. the inner list is the + // in practice, these are the xaxis names that are linked to each other (i.e. the inner list is the // xaxis names of the subplot columns) // e.g.: [ [xaxis1, xaxis2], [xaxis3, xaxis4] ] let shared_axes_list = _.chain(main_graphDiv.layout) @@ -127,7 +127,7 @@ window.dash_clientside = Object.assign({}, window.dash_clientside, { }; // iterate over the selected data range - console.log('selected data range', selectedData.range); + console.log("selected data range", selectedData.range); for (const anchor_key in selectedData.range) { const selected_range = selectedData.range[anchor_key]; // Obtain the anchor key of the orthogonal axis (x or y), based on the coarse graphdiv anchor pairs @@ -157,6 +157,7 @@ window.dash_clientside = Object.assign({}, window.dash_clientside, { Object.keys(relayout).length > 0 ? Plotly.relayout(main_graphDiv, relayout) : null; return mainFigID; }, + main_to_coarse: function (mainRelayout, coarseFigID, mainFigID) { const coarse_graphDiv = getGraphDiv(coarseFigID); const main_graphDiv = getGraphDiv(mainFigID); @@ -180,6 +181,8 @@ window.dash_clientside = Object.assign({}, window.dash_clientside, { }; }; + // console.log("main to coarse", mainRelayout); + // Base case; no selections yet on the coarse graph if (!currentSelections) { // if current selections is None @@ -220,11 +223,9 @@ window.dash_clientside = Object.assign({}, window.dash_clientside, { ) { performed_update = true; if ( - // mainRelayout[x_axis_key + ".showspikes"] === false && - // mainRelayout[y_axis_key + ".showspikes"] === false - // NOTE: for some reason, showspikes info is only availabel for the xaxis & yaxis keys - mainRelayout["xaxis.showspikes"] === false && - mainRelayout["yaxis.showspikes"] === false + // NOTE: for some reason, showspikes info is only available for the xaxis & yaxis keys + _.has(mainRelayout, "xaxis.showspikes") && + _.has(mainRelayout, "yaxis.showspikes") ) { // reset axis -> we use the coarse graphDiv layout x_range = coarse_graphDiv.layout[x_axis_key].range; From 3d43a2dec14a7a5c18344daa2ce8ab7b59795fa0 Mon Sep 17 00:00:00 2001 From: Jeroen Van Der Donckt Date: Mon, 8 Jan 2024 10:02:38 +0100 Subject: [PATCH 02/11] :broom: review code --- plotly_resampler/figure_resampler/assets/coarse_fine.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plotly_resampler/figure_resampler/assets/coarse_fine.js b/plotly_resampler/figure_resampler/assets/coarse_fine.js index 3b55de70..ee0a0b58 100644 --- a/plotly_resampler/figure_resampler/assets/coarse_fine.js +++ b/plotly_resampler/figure_resampler/assets/coarse_fine.js @@ -127,7 +127,7 @@ window.dash_clientside = Object.assign({}, window.dash_clientside, { }; // iterate over the selected data range - console.log("selected data range", selectedData.range); + // console.log("selected data range", selectedData.range); for (const anchor_key in selectedData.range) { const selected_range = selectedData.range[anchor_key]; // Obtain the anchor key of the orthogonal axis (x or y), based on the coarse graphdiv anchor pairs @@ -181,13 +181,11 @@ window.dash_clientside = Object.assign({}, window.dash_clientside, { }; }; - // console.log("main to coarse", mainRelayout); - // Base case; no selections yet on the coarse graph if (!currentSelections) { // if current selections is None coarse_xy_axiskeys.forEach((xy_pair) => { - console.log("xy pair", xy_pair); + // console.log("xy pair", xy_pair); const x_axis_key = _.has(layout_axis_anchors, xy_pair.y) ? layout_axis_anchors[xy_pair.y] : "xaxis"; const y_axis_key = _.has(layout_axis_anchors, xy_pair.x) ? layout_axis_anchors[xy_pair.x] : "yaxis"; // console.log('xaxis key', x_axis_key, main_graphDiv.layout[x_axis_key]); From 4125b1bd58b5846dc14782e424572ee295aebf8b Mon Sep 17 00:00:00 2001 From: jonasvdd Date: Wed, 10 Jan 2024 21:43:47 +0100 Subject: [PATCH 03/11] :sparkles: new example --- examples/README.md | 1 + .../06_cache_overview_range_buttons.py | 191 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 examples/dash_apps/06_cache_overview_range_buttons.py 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/06_cache_overview_range_buttons.py b/examples/dash_apps/06_cache_overview_range_buttons.py new file mode 100644 index 00000000..0eb5072b --- /dev/null +++ b/examples/dash_apps/06_cache_overview_range_buttons.py @@ -0,0 +1,191 @@ +"""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. + +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" + +# fmt: off +# fmt: on + +# --------------------------------------Globals --------------------------------------- +# NOTE: Remark how the assests 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 servside 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) From 4c12ab4313155fa7a0beb79659b56bd528c413c0 Mon Sep 17 00:00:00 2001 From: jonasvdd Date: Fri, 12 Jan 2024 14:34:06 +0100 Subject: [PATCH 04/11] :mag: reviewing examples --- examples/dash_apps/04_minimal_cache_overview.py | 4 ++-- examples/dash_apps/05_cache_overview_subplots.py | 4 ++-- examples/dash_apps/06_cache_overview_range_buttons.py | 9 ++++----- 3 files changed, 8 insertions(+), 9 deletions(-) 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 index 0eb5072b..fe4b6934 100644 --- a/examples/dash_apps/06_cache_overview_range_buttons.py +++ b/examples/dash_apps/06_cache_overview_range_buttons.py @@ -11,6 +11,8 @@ 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 @@ -42,11 +44,8 @@ STORE_ID = "store" PLOT_BTN_ID = "plot-button" -# fmt: off -# fmt: on - # --------------------------------------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__, @@ -60,7 +59,7 @@ 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 servside store for the FigureResampler graph + # The graph, overview graph, and serverside store for the FigureResampler graph dcc.Graph( id=GRAPH_ID, config={"modeBarButtonsToRemove": ["resetscale"]}, From 66b247af74acafdf702ddc26cee1801437942b3c Mon Sep 17 00:00:00 2001 From: jonasvdd Date: Fri, 12 Jan 2024 14:49:33 +0100 Subject: [PATCH 05/11] :crayon: docs-fix for #275 --- plotly_resampler/registering.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 ---------- From a211f39c290c315071fc1ac426405f2c1c6273e1 Mon Sep 17 00:00:00 2001 From: jonasvdd Date: Sat, 13 Jan 2024 19:54:15 +0100 Subject: [PATCH 06/11] :tada: first draft of candlestick support --- plotly_resampler/__init__.py | 3 +- plotly_resampler/aggregation/__init__.py | 2 + plotly_resampler/aggregation/aggregators.py | 28 +++++++ .../figure_resampler_interface.py | 75 +++++++++++++++++-- 4 files changed, 100 insertions(+), 8 deletions(-) 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] From 7612315de426553644701f9257c4b35d69ddcde3 Mon Sep 17 00:00:00 2001 From: Jonas Van Der Donckt <38005924+jonasvdd@users.noreply.github.com> Date: Sun, 14 Jan 2024 19:32:31 +0100 Subject: [PATCH 07/11] Bug/rangeselector (#287) * :sparkles: fix for #275 * :broom: review code * :sparkles: new example * :mag: reviewing examples * :crayon: docs-fix for #275 * :mag: review --------- Co-authored-by: Jeroen Van Der Donckt --- examples/README.md | 1 + .../dash_apps/04_minimal_cache_overview.py | 4 +- .../dash_apps/05_cache_overview_subplots.py | 4 +- .../06_cache_overview_range_buttons.py | 193 ++++++++++++++++++ plotly_resampler/registering.py | 10 +- 5 files changed, 204 insertions(+), 8 deletions(-) create mode 100644 examples/dash_apps/06_cache_overview_range_buttons.py 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 ---------- From 2f0eba8bd878865a8554df9617651e0d5305d051 Mon Sep 17 00:00:00 2001 From: jonasvdd Date: Sun, 14 Jan 2024 19:36:42 +0100 Subject: [PATCH 08/11] :rocket: v0.9.2 release --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 32b3a8e429d4c19b9ae44e0cc3e140016060e3d7 Mon Sep 17 00:00:00 2001 From: jonasvdd Date: Sat, 6 Jan 2024 19:21:00 +0100 Subject: [PATCH 09/11] :sparkles: fix for #275 --- plotly_resampler/figure_resampler/assets/coarse_fine.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plotly_resampler/figure_resampler/assets/coarse_fine.js b/plotly_resampler/figure_resampler/assets/coarse_fine.js index ee0a0b58..407064aa 100644 --- a/plotly_resampler/figure_resampler/assets/coarse_fine.js +++ b/plotly_resampler/figure_resampler/assets/coarse_fine.js @@ -181,6 +181,8 @@ window.dash_clientside = Object.assign({}, window.dash_clientside, { }; }; + // console.log("main to coarse", mainRelayout); + // Base case; no selections yet on the coarse graph if (!currentSelections) { // if current selections is None From b945dfbed40cd2347d6f076db3f81c414449193e Mon Sep 17 00:00:00 2001 From: Jeroen Van Der Donckt Date: Mon, 8 Jan 2024 10:02:38 +0100 Subject: [PATCH 10/11] :broom: review code --- plotly_resampler/figure_resampler/assets/coarse_fine.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/plotly_resampler/figure_resampler/assets/coarse_fine.js b/plotly_resampler/figure_resampler/assets/coarse_fine.js index 407064aa..ee0a0b58 100644 --- a/plotly_resampler/figure_resampler/assets/coarse_fine.js +++ b/plotly_resampler/figure_resampler/assets/coarse_fine.js @@ -181,8 +181,6 @@ window.dash_clientside = Object.assign({}, window.dash_clientside, { }; }; - // console.log("main to coarse", mainRelayout); - // Base case; no selections yet on the coarse graph if (!currentSelections) { // if current selections is None From 3830632415c83ce4b41a5008005063d3f9a7622d Mon Sep 17 00:00:00 2001 From: jonasvdd Date: Sat, 13 Jan 2024 19:54:15 +0100 Subject: [PATCH 11/11] :tada: first draft of candlestick support --- plotly_resampler/__init__.py | 3 +- plotly_resampler/aggregation/__init__.py | 2 + plotly_resampler/aggregation/aggregators.py | 28 +++++++ .../figure_resampler_interface.py | 75 +++++++++++++++++-- 4 files changed, 100 insertions(+), 8 deletions(-) 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]