From 7903a8f34449e9d8d75a573b4a88449024f3c268 Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Sun, 1 May 2022 00:01:00 +0200 Subject: [PATCH 01/19] :octopus: first draft :fallen_leaf: --- plotly_resampler/figure_resampler.py | 1154 ++++++++++++++++++++++++-- 1 file changed, 1092 insertions(+), 62 deletions(-) diff --git a/plotly_resampler/figure_resampler.py b/plotly_resampler/figure_resampler.py index e4058c73..fa9c535d 100644 --- a/plotly_resampler/figure_resampler.py +++ b/plotly_resampler/figure_resampler.py @@ -11,32 +11,945 @@ from __future__ import annotations +from flask import g + __author__ = "Jonas Van Der Donckt, Jeroen Van Der Donckt, Emiel Deprost" -import re -import warnings -from copy import copy -from typing import Dict, Iterable, List, Optional, Tuple, Union -from uuid import uuid4 +import re +import warnings +from copy import copy +from typing import Dict, Iterable, List, Optional, Tuple, Union +from uuid import uuid4 +from itertools import chain + +import dash +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from jupyter_dash import JupyterDash +from dash import Dash +from plotly.basedatatypes import BaseTraceType, BaseFigure +from trace_updater import TraceUpdater + +from .aggregation import AbstractSeriesAggregator, EfficientLTTB +from .utils import round_td_str, round_number_str + +from abc import ABC + + +class AbstractFigureAggregator(BaseFigure, ABC): + def __init__( + self, + figure: BaseFigure, + convert_existing_traces: bool = True, + default_n_shown_samples: int = 1000, + default_downsampler: AbstractSeriesAggregator = EfficientLTTB(), + resampled_trace_prefix_suffix: Tuple[str, str] = ( + '[R] ', + "", + ), + show_mean_aggregation_size: bool = True, + verbose: bool = False, + ): + """Instantiate a resampling data mirror. + + Parameters + ---------- + figure: BaseFigure + The figure that will be decorated. Can be either an empty figure + (e.g., ``go.Figure()``, ``make_subplots()``, ``go.FigureWidget``) or an + existing figure, by default a go.Figure(). + convert_existing_traces: bool + A bool indicating whether the high-frequency traces of the passed ``figure`` + should be resampled, by default True. Hence, when set to False, the + high-frequency traces of the passed ``figure`` will not be resampled. + default_n_shown_samples: int, optional + The default number of samples that will be shown for each trace, + by default 1000.\n + .. note:: + * This can be overridden within the :func:`add_trace` method. + * If a trace withholds fewer datapoints than this parameter, + the data will *not* be aggregated. + default_downsampler: AbstractSeriesDownsampler + An instance which implements the AbstractSeriesDownsampler interface and + will be used as default downsampler, by default ``EfficientLTTB`` with + _interleave_gaps_ set to True. \n + .. note:: This can be overridden within the :func:`add_trace` method. + resampled_trace_prefix_suffix: str, optional + A tuple which contains the ``prefix`` and ``suffix``, respectively, which + will be added to the trace its legend-name when a resampled version of the + trace is shown. By default a bold, orange ``[R]`` is shown as prefix + (no suffix is shown). + show_mean_aggregation_size: bool, optional + Whether the mean aggregation bin size will be added as a suffix to the trace + its legend-name, by default True. + verbose: bool, optional + Whether some verbose messages will be printed or not, by default False. + + """ + self._hf_data: Dict[str, dict] = {} + self._global_n_shown_samples = default_n_shown_samples + self._print_verbose = verbose + self._show_mean_aggregation_size = show_mean_aggregation_size + + assert len(resampled_trace_prefix_suffix) == 2 + self._prefix, self._suffix = resampled_trace_prefix_suffix + + self._global_downsampler = default_downsampler + + if convert_existing_traces: + # call __init__ with the correct layout and set the `_grid_ref` of the + # to-be-converted figure + f_ = figure.__class__(layout=figure.layout) + f_._grid_ref = figure._grid_ref + super().__init__(f_) + + for trace in figure.data: + self.add_trace(trace) + else: + super().__init__(figure) + + def _print(self, *values): + """Helper method for printing if ``verbose`` is set to True.""" + if self._print_verbose: + print(*values) + + def _query_hf_data(self, trace: dict) -> Optional[dict]: + """Query the internal ``_hf_data`` attribute and returns a match based on + ``uid``. + + Parameters + ---------- + trace : dict + The trace where we want to find a match for. + + Returns + ------- + Optional[dict] + The ``hf_data``-trace dict if a match is found, else ``None``. + + """ + uid = trace["uid"] + hf_trace_data = self._hf_data.get(uid) + if hf_trace_data is None: + trace_props = { + k: trace[k] for k in set(trace.keys()).difference({"x", "y"}) + } + self._print(f"[W] trace with {trace_props} not found") + return hf_trace_data + + def _get_current_graph(self) -> dict: + """Create an efficient copy of the current graph by omitting the "hovertext", + "x", and "y" properties of each trace. + + Returns + ------- + dict + The current graph dict + + See Also + -------- + https://github.com/plotly/plotly.py/blob/2e7f322c5ea4096ce6efe3b4b9a34d9647a8be9c/packages/python/plotly/plotly/basedatatypes.py#L3278 + """ + return { + "data": [ + { + k: copy(trace[k]) + for k in set(trace.keys()).difference({"x", "y", "hovertext"}) + } + for trace in self._data + ], + "layout": copy(self._layout), + } + + def _check_update_trace_data( + self, + trace: dict, + start=None, + end=None, + ) -> Optional[Union[dict, BaseTraceType]]: + """Check and update the passed ``trace`` its data properties based on the + slice range. + + Note + ---- + This is a pass by reference. The passed trace object will be updated and + returned if found in ``hf_data``. + + Parameters + ---------- + trace : BaseTraceType or dict + - An instances of a trace class from the ``plotly.graph_objects`` (go) + package (e.g, ``go.Scatter``, ``go.Bar``) + - or a dict where: + + - The 'type' property specifies the trace type (e.g. + 'scatter', 'bar', 'area', etc.). If the dict has no 'type' + property then 'scatter' is assumed. + - All remaining properties are passed to the constructor + of the specified trace type. + + start : Union[float, str], optional + The start index for which we want resampled data to be updated to, + by default None, + end : Union[float, str], optional + The end index for which we want the resampled data to be updated to, + by default None + + Returns + ------- + Optional[Union[dict, BaseTraceType]] + If the matching ``hf_series`` is found in ``hf_dict``, an (updated) trace + will be returned, otherwise None. + + Note + ---- + * If ``start`` and ``stop`` are strings, they most likely represent time-strings + * ``start`` and ``stop`` will always be of the same type (float / time-string) + because their underlying axis is the same. + + """ + hf_trace_data = self._query_hf_data(trace) + if hf_trace_data is not None: + axis_type = hf_trace_data["axis_type"] + if axis_type == "date": + start, end = pd.to_datetime(start), pd.to_datetime(end) + hf_series = self._slice_time( + self._to_hf_series(hf_trace_data["x"], hf_trace_data["y"]), + start, + end, + ) + else: + hf_series = self._to_hf_series(hf_trace_data["x"], hf_trace_data["y"]) + start = hf_series.index[0] if start is None else start + end = hf_series.index[-1] if end is None else end + if hf_series.index.is_integer(): + start = round(start) + end = round(end) + + # Search the index-positions + start_idx, end_idx = np.searchsorted(hf_series.index, [start, end]) + hf_series = hf_series.iloc[start_idx:end_idx] + + # Return an invisible, single-point, trace when the sliced hf_series doesn't + # contain any data in the current view + if len(hf_series) == 0: + trace["x"] = [start] + trace["y"] = [None] + trace["hovertext"] = "" + return trace + + # Downsample the data and store it in the trace-fields + downsampler: AbstractSeriesAggregator = hf_trace_data["downsampler"] + s_res: pd.Series = downsampler.aggregate( + hf_series, hf_trace_data["max_n_samples"] + ) + trace["x"] = s_res.index + trace["y"] = s_res.values + # todo -> first draft & not MP safe + + agg_prefix, agg_suffix = ' ~', "" + name: str = trace["name"].split(agg_prefix)[0] + + if len(hf_series) > hf_trace_data["max_n_samples"]: + name = ("" if name.startswith(self._prefix) else self._prefix) + name + name += self._suffix if not name.endswith(self._suffix) else "" + # Add the mean aggregation bin size to the trace name + if self._show_mean_aggregation_size: + agg_mean = np.mean(np.diff(s_res.index.values)) + if isinstance(agg_mean, np.timedelta64): + agg_mean = round_td_str(pd.Timedelta(agg_mean)) + else: + agg_mean = round_number_str(agg_mean) + name += f"{agg_prefix}{agg_mean}{agg_suffix}" + else: + # When not resampled: trim prefix and/or suffix if necessary + if len(self._prefix) and name.startswith(self._prefix): + name = name[len(self._prefix) :] + if len(self._suffix) and trace["name"].endswith(self._suffix): + name = name[: -len(self._suffix)] + trace["name"] = name + + # Check if hovertext also needs to be resampled + hovertext = hf_trace_data.get("hovertext") + if isinstance(hovertext, pd.Series): + # TODO -> this can be optimized + trace["hovertext"] = pd.merge_asof( + s_res, + hovertext, + left_index=True, + right_index=True, + direction="nearest", + )[hovertext.name].values + else: + trace["hovertext"] = hovertext + return trace + else: + self._print("hf_data not found") + return None + + def _check_update_figure_dict( + self, + figure: dict, + start: Optional[Union[float, str]] = None, + stop: Optional[Union[float, str]] = None, + xaxis_filter: str = None, + updated_trace_indices: Optional[List[int]] = None, + ) -> List[int]: + """Check and update the traces within the figure dict. + + hint + ---- + This method will most likely be used within a ``Dash`` callback to resample the + view, based on the configured number of parameters. + + Note + ---- + This is a pass by reference. The passed figure object will be updated. + No new view of this figure will be created, hence no return! + + Parameters + ---------- + figure : dict + The figure dict which will be updated. + start : Union[float, str], optional + The start time for the new resampled data view, by default None. + stop : Union[float, str], optional + The end time for the new resampled data view, by default None. + xaxis_filter: str, optional + Additional trace-update subplot filter, by default None. + updated_trace_indices: List[int], optional + List of trace indices that already have been updated, by default None. + + Returns + ------- + List[int] + A list of indices withholding the trace-data-array-index from the of data + modalities which are updated. + + """ + xaxis_filter_short = None + if xaxis_filter is not None: + xaxis_filter_short = "x" + xaxis_filter.lstrip("xaxis") + + if updated_trace_indices is None: + updated_trace_indices = [] + + for idx, trace in enumerate(figure["data"]): + # We skip when the trace-idx already has been updated. + if idx in updated_trace_indices: + continue + + if xaxis_filter is not None: + # the x-anchor of the trace is stored in the layout data + if trace.get("yaxis") is None: + # no yaxis -> we make the assumption that yaxis = xaxis_filter_short + y_axis = "y" + xaxis_filter[1:] + else: + y_axis = "yaxis" + trace.get("yaxis")[1:] + + # Next to the x-anchor, we also fetch the xaxis which matches the + # current trace (i.e. if this value is not None, the axis shares the + # x-axis with one or more traces). + # This is relevant when e.g. fig.update_traces(xaxis='x...') was called. + x_anchor_trace = figure["layout"].get(y_axis, {}).get("anchor") + if x_anchor_trace is not None: + xaxis_matches = ( + figure["layout"] + .get("xaxis" + x_anchor_trace.lstrip("x"), {}) + .get("matches") + ) + else: + xaxis_matches = figure["layout"].get("xaxis", {}).get("matches") + + # print( + # f"x_anchor: {x_anchor_trace} - xaxis_filter: {xaxis_filter} ", + # f"- xaxis_matches: {xaxis_matches}" + # ) + + # We skip when: + # * the change was made on the first row and the trace its anchor is not + # in [None, 'x'] and the matching (a.k.a. shared) xaxis is not equal + # to the xaxis filter argument. + # -> why None: traces without row/col argument and stand on first row + # and do not have the anchor property (hence the DICT.get() method) + # * x_axis_filter_short not in [x_anchor or xaxis matches] for + # NON first rows + if ( + xaxis_filter_short == "x" + and ( + x_anchor_trace not in [None, "x"] + and xaxis_matches != xaxis_filter_short + ) + ) or ( + xaxis_filter_short != "x" + and (xaxis_filter_short not in [x_anchor_trace, xaxis_matches]) + ): + continue + + # If we managed to find and update the trace, it will return the trace + # and thus not None. + updated_trace = self._check_update_trace_data(trace, start=start, end=stop) + if updated_trace is not None: + updated_trace_indices.append(idx) + return updated_trace_indices + + @staticmethod + def _slice_time( + hf_series: pd.Series, + t_start: Optional[pd.Timestamp] = None, + t_stop: Optional[pd.Timestamp] = None, + ) -> pd.Series: + """Slice the time-indexed ``hf_series`` for the passed pd.Timestamps. + + Note + ---- + This returns a **view** of ``hf_series``! + + Parameters + ---------- + hf_series: pd.Series + The **datetime-indexed** series, which will be sliced. + t_start: pd.Timestamp, optional + The lower-time-bound of the slice, if set to None, no lower-bound threshold + will be applied, by default None. + t_stop: pd.Timestamp, optional + The upper time-bound of the slice, if set to None, no upper-bound threshold + will be applied, by default None. + + Returns + ------- + pd.Series + The sliced **view** of the series. + + """ + + def to_same_tz( + ts: Union[pd.Timestamp, None], reference_tz=hf_series.index.tz + ) -> Union[pd.Timestamp, None]: + """Adjust `ts` its timezone to the `reference_tz`.""" + if ts is None: + return None + elif reference_tz is not None: + if ts.tz is not None: + assert ts.tz.zone == reference_tz.zone + return ts + else: # localize -> time remains the same + return ts.tz_localize(reference_tz) + elif reference_tz is None and ts.tz is not None: + return ts.tz_localize(None) + return ts + + if t_start is not None and t_stop is not None: + assert t_start.tz == t_stop.tz + + return hf_series[to_same_tz(t_start) : to_same_tz(t_stop)] + + @property + def hf_data(self): + """Property to adjust the `data` component of the current graph + + .. note:: + The user has full responisbility to adjust ``hf_data`` properly. + + + Example: + >>> fig = FigureResampler(go.Figure()) + >>> fig.add_trace(...) + >>> fig.hf_data[-1]["y"] = - s ** 2 # adjust the y-property of the trace added above + >>> fig._hf_data + [ + { + 'max_n_samples': 1000, + 'x': RangeIndex(start=0, stop=11000000, step=1), + 'y': array([-0.01339909, 0.01390696,, ..., 0.25051913, 0.55876513]), + 'axis_type': 'linear', + 'downsampler': , + 'hovertext': None + }, + ] + """ + return list(self._hf_data.values()) + + @staticmethod + def _to_hf_series(x: np.ndarray, y: np.ndarray) -> pd.Series: + """Construct the hf-series. + + Parameters + ---------- + x : np.ndarray + The hf_series index + y : np.ndarray + The hf_series values + + Returns + ------- + pd.Series + The constructed hf_series + """ + return pd.Series( + data=y, + index=x, + copy=False, + name="data", + dtype="category" if y.dtype.type == np.str_ else y.dtype, + ) + + def add_trace( + self, + trace: Union[BaseTraceType, dict], + max_n_samples: int = None, + downsampler: AbstractSeriesAggregator = None, + limit_to_view: bool = False, + # Use these if you want some speedups (and are working with really large data) + hf_x: Iterable = None, + hf_y: Iterable = None, + hf_hovertext: Union[str, Iterable] = None, + **trace_kwargs, + ): + """Add a trace to the figure. + + Parameters + ---------- + trace : BaseTraceType or dict + Either: + + - An instances of a trace class from the ``plotly.graph_objects`` (go) + package (e.g., ``go.Scatter``, ``go.Bar``) + - or a dict where: + + - The type property specifies the trace type (e.g. scatter, bar, + area, etc.). If the dict has no 'type' property then scatter is + assumed. + - All remaining properties are passed to the constructor + of the specified trace type. + max_n_samples : int, optional + The maximum number of samples that will be shown by the trace.\n + .. note:: + If this variable is not set; ``_global_n_shown_samples`` will be used. + downsampler: AbstractSeriesDownsampler, optional + The abstract series downsampler method.\n + .. note:: + If this variable is not set, ``_global_downsampler`` will be used. + limit_to_view: boolean, optional + If set to True the trace's datapoints will be cut to the corresponding + front-end view, even if the total number of samples is lower than + ``max_n_samples``, By default False. + hf_x: Iterable, optional + The original high frequency series positions, can be either a time-series or + an increasing, numerical index. If set, this has priority over the trace its + data. + hf_y: Iterable, optional + The original high frequency values. If set, this has priority over the + trace its data. + hf_hovertext: Iterable, optional + The original high frequency hovertext. If set, this has priority over the + trace its ``text`` or ``hovertext`` argument. + **trace_kwargs: dict + Additional trace related keyword arguments. + e.g.: row=.., col=..., secondary_y=... + + .. seealso:: + `Figure.add_trace `_ docs. + + Returns + ------- + BaseFigure + The Figure on which ``add_trace`` was called on; i.e. self. + + Note + ---- + Constructing traces with **very large data amounts** really takes some time. + To speed this up; use this :func:`add_trace` method and + + 1. Create a trace with no data (empty lists) + 2. pass the high frequency data to this method using the ``hf_x`` and ``hf_y`` + parameters. + + See the example below: + + >>> from plotly.subplots import make_subplots + >>> s = pd.Series() # a high-frequency series, with more than 1e7 samples + >>> fig = FigureResampler(go.Figure()) + >>> fig.add_trace(go.Scattergl(x=[], y=[], ...), hf_x=s.index, hf_y=s) + + .. todo:: + * explain why adding x and y to a trace is so slow + * check and simplify the example above + + Tip + --- + * If you **do not want to downsample** your data, set ``max_n_samples`` to the + the number of datapoints of your trace! + + Attention + --------- + * The ``NaN`` values in either ``hf_y`` or ``trace.y`` will be omitted! We do + not allow ``NaN`` values in ``hf_x`` or ``trace.x``. + * ``hf_x``, ``hf_y``, and ``hf_hovertext`` are useful when you deal with large + amounts of data (as it can increase the speed of this add_trace() method with + ~30%). These arguments have priority over the trace's data and (hover)text + attributes. + * Low-frequency time-series data, i.e. traces that are not resampled, can hinder + the the automatic-zooming (y-scaling) as these will not be stored in the + back-end and thus not be scaled to the view. + To circumvent this, the ``limit_to_view`` argument can be set, resulting in + also storing the low-frequency series in the back-end. + + """ + if max_n_samples is None: + max_n_samples = self._global_n_shown_samples + + # First add the trace, as each (even the non-hf_data traces), must contain this + # key for comparison + trace.uid = str(uuid4()) + + hf_x = ( + trace["x"] + if hasattr(trace, "x") and hf_x is None + else hf_x.values + if isinstance(hf_x, pd.Series) + else hf_x + ) + if isinstance(hf_x, tuple): + hf_x = list(hf_x) + + hf_y = ( + trace["y"] + if hasattr(trace, "y") and hf_y is None + else hf_y.values + if isinstance(hf_y, pd.Series) + else hf_y + ) + hf_y = np.asarray(hf_y) + + # Note: "hovertext" takes precedence over "text" + hf_hovertext = ( + hf_hovertext + if hf_hovertext is not None + else trace["hovertext"] + if hasattr(trace, "hovertext") and trace["hovertext"] is not None + else trace["text"] + if hasattr(trace, "text") + else None + ) + + high_frequency_traces = ["scatter", "scattergl"] + if trace["type"].lower() in high_frequency_traces: + if hf_x is None: # if no data as x or hf_x is passed + if hf_y.ndim != 0: # if hf_y is an array + hf_x = np.arange(len(hf_y)) + else: # if no data as y or hf_y is passed + hf_x = np.asarray(None) + + assert hf_y.ndim == np.ndim(hf_x), ( + "plotly-resampler requires scatter data " + "(i.e., x and y, or hf_x and hf_y) to have the same dimensionality!" + ) + # When the x or y of a trace has more than 1 dimension, it is not at all + # straightforward how it should be resampled. + assert hf_y.ndim <= 1 and np.ndim(hf_x) <= 1, ( + "plotly-resampler requires scatter data " + "(i.e., x and y, or hf_x and hf_y) to be <= 1 dimensional!" + ) + + # Make sure to set the text-attribute to None as the default plotly behavior + # for these high-dimensional traces (scatters) is that text will be shown in + # hovertext and not in on-graph texts (as is the case with bar-charts) + trace["text"] = None + + # Note: this also converts hf_hovertext to a np.ndarray + if isinstance(hf_hovertext, (list, np.ndarray, pd.Series)): + hf_hovertext = np.asarray(hf_hovertext) + + # Remove NaNs for efficiency (storing less meaningless data) + # NaNs introduce gaps between enclosing non-NaN data points & might distort + # the resampling algorithms + if pd.isna(hf_y).any(): + not_nan_mask = ~pd.isna(hf_y) + hf_x = hf_x[not_nan_mask] + hf_y = hf_y[not_nan_mask] + if isinstance(hf_hovertext, np.ndarray): + hf_hovertext = hf_hovertext[not_nan_mask] + + # If the categorical or string-like hf_y data is of type object (happens + # when y argument is used for the trace constructor instead of hf_y), we + # transform it to type string as such it will be sent as categorical data + # to the downsampling algorithm + if hf_y.dtype == "object": + hf_y = hf_y.astype("str") + + # orjson encoding doesn't like to encode with uint8 & uint16 dtype + if str(hf_y.dtype) in ["uint8", "uint16"]: + hf_y = hf_y.astype("uint32") + + assert len(hf_x) == len(hf_y), "x and y have different length!" + + # Convert the hovertext to a pd.Series if it's now a np.ndarray + # Note: The size of hovertext must be the same size as hf_x otherwise a + # ValueError will be thrown + if isinstance(hf_hovertext, np.ndarray): + hf_hovertext = pd.Series( + data=hf_hovertext, index=hf_x, copy=False, name="hovertext" + ) + + n_samples = len(hf_x) + # These traces will determine the autoscale RANGE! + # -> so also store when `limit_to_view` is set. + if n_samples > max_n_samples or limit_to_view: + self._print( + f"\t[i] DOWNSAMPLE {trace['name']}\t{n_samples}->{max_n_samples}" + ) + + # We will re-create this each time as hf_x and hf_y withholds + # high-frequency data + # index = pd.Index(hf_x, copy=False, name="timestamp") + hf_series = self._to_hf_series(x=hf_x, y=hf_y) + + # Checking this now avoids less interpretable `KeyError` when resampling + assert hf_series.index.is_monotonic_increasing + + # As we support prefix-suffixing of downsampled data, we assure that + # each trace has a name + # https://github.com/plotly/plotly.py/blob/ce0ed07d872c487698bde9d52e1f1aadf17aa65f/packages/python/plotly/plotly/basedatatypes.py#L539 + # The link above indicates that the trace index is derived from `data` + if trace.name is None: + trace.name = f"trace {len(self.data)}" + + # Determine (1) the axis type and (2) the downsampler instance + # & (3) store a hf_data entry for the corresponding trace, + # identified by its UUID + axis_type = "date" if isinstance(hf_x, pd.DatetimeIndex) else "linear" + d = self._global_downsampler if downsampler is None else downsampler + self._hf_data[trace.uid] = { + "max_n_samples": max_n_samples, + "x": hf_x, + "y": hf_y, + "axis_type": axis_type, + "downsampler": d, + "hovertext": hf_hovertext, + } + + # Before we update the trace, we create a new pointer to that trace in + # which the downsampled data will be stored. This way, the original + # data of the trace to this `add_trace` method will not be altered. + # We copy (by reference) all the non-data properties of the trace in + # the new trace. + if not isinstance(trace, dict): + trace = trace._props + trace = { + k: trace[k] + for k in set(trace.keys()).difference( + {"text", "hovertext", "x", "y"} + ) + } + + # NOTE: + # If all the raw data needs to be sent to the javascript, and the trace + # is high-frequency, this would take significant time! + # Hence, you first downsample the trace. + trace = self._check_update_trace_data(trace) + assert trace is not None + return super().add_trace(trace=trace, **trace_kwargs) + else: + self._print(f"[i] NOT resampling {trace['name']} - len={n_samples}") + trace.x = hf_x + trace.y = hf_y + trace.text = hf_hovertext + return super().add_trace(trace=trace, **trace_kwargs) + else: + self._print(f"trace {trace['type']} is not a high-frequency trace") + + # hf_x and hf_y have priority over the traces' data + if hasattr(trace, "x"): + trace["x"] = hf_x + + if hasattr(trace, "y"): + trace["y"] = hf_y + + if hasattr(trace, "text") and hasattr(trace, "hovertext"): + trace["text"] = None + trace["hovertext"] = hf_hovertext + + return super().add_trace(trace=trace, **trace_kwargs) + + # def add_traces(*args, **kwargs): + # raise NotImplementedError("This functionality is not (yet) supported") + + def _clear_figure(self): + """Clear the current figure object it's data and layout.""" + self._hf_data = {} + self.data = [] + self.layout = {} -import dash -import numpy as np -import pandas as pd -import plotly.graph_objects as go -from jupyter_dash import JupyterDash -from plotly.basedatatypes import BaseTraceType -from trace_updater import TraceUpdater + def replace(self, figure: go.Figure, convert_existing_traces: bool = True): + """Replace the current figure layout with the passed figure object. -from .aggregation import AbstractSeriesAggregator, EfficientLTTB -from .utils import round_td_str, round_number_str + Parameters + ---------- + figure: go.Figure + The figure object which will replace the existing figure. + convert_existing_traces: bool, Optional + A bool indicating whether the traces of the passed ``figure`` should be + resampled, by default True. + + """ + self._clear_figure() + self.__init__( + figure=figure, + convert_existing_traces=convert_existing_traces, + default_n_shown_samples=self._global_n_shown_samples, + default_downsampler=self._global_downsampler, + resampled_trace_prefix_suffix=(self._prefix, self._suffix), + ) + + def construct_update_data(self, relayout_data: dict) -> List[dict]: + """Construct the to-be-updated front-end data, based on the layout change. + + Attention + --------- + This method is tightly coupled with Dash app callbacks. It takes the front-end + figure its ``relayoutData`` as input and returns the data which needs to be + sent tot the ``TraceUpdater`` its ``updateData`` property for that corresponding + graph. + + Parameters + ---------- + relayout_data: dict + A dict containing the ``relayout``-data (a.k.a. changed layout data) of + the corresponding front-end graph. + + Returns + ------- + List[dict]: + A list of dicts, where each dict-item is a representation of a trace its + *data* properties which are affected by the front-end layout change. |br| + In other words, only traces which need to be updated will be sent to the + front-end. Additionally, each trace-dict withholds the *index* of its + corresponding position in the ``figure[data]`` array with the ``index``-key + in each dict. + + """ + current_graph = self._get_current_graph() + updated_trace_indices, cl_k = [], [] + if relayout_data: + self._print("-" * 100 + "\n", "changed layout", relayout_data) + + cl_k = relayout_data.keys() + + # ------------------ HF DATA aggregation --------------------- + # 1. Base case - there is a x-range specified in the front-end + start_matches = self._re_matches(re.compile(r"xaxis\d*.range\[0]"), cl_k) + stop_matches = self._re_matches(re.compile(r"xaxis\d*.range\[1]"), cl_k) + if len(start_matches) and len(stop_matches): + for t_start_key, t_stop_key in zip(start_matches, stop_matches): + # Check if the xaxis part of xaxis.[0-1] matches + xaxis = t_start_key.split(".")[0] + assert xaxis == t_stop_key.split(".")[0] + # -> we want to copy the layout on the back-end + updated_trace_indices = self._check_update_figure_dict( + current_graph, + start=relayout_data[t_start_key], + stop=relayout_data[t_stop_key], + xaxis_filter=xaxis, + updated_trace_indices=updated_trace_indices, + ) + + # 2. The user clicked on either autorange | reset axes + autorange_matches = self._re_matches( + re.compile(r"xaxis\d*.autorange"), cl_k + ) + spike_matches = self._re_matches(re.compile(r"xaxis\d*.showspikes"), cl_k) + # 2.1 Reset-axes -> autorange & reset to the global data view + if len(autorange_matches) and len(spike_matches): + for autorange_key in autorange_matches: + if relayout_data[autorange_key]: + xaxis = autorange_key.split(".")[0] + updated_trace_indices = self._check_update_figure_dict( + current_graph, + xaxis_filter=xaxis, + updated_trace_indices=updated_trace_indices, + ) + # 2.1. Autorange -> do nothing, the autorange will be applied on the + # current front-end view + elif len(autorange_matches) and not len(spike_matches): + # PreventUpdate returns a 204 status code response on the + # relayout post request + raise dash.exceptions.PreventUpdate() + + # If we do not have any traces to be updated, we will return an empty + # request response + if len(updated_trace_indices) == 0: + # PreventUpdate returns a 204 status-code response on the relayout post + # request + raise dash.exceptions.PreventUpdate() + + # -------------------- construct callback data -------------------------- + layout_traces_list: List[dict] = [] # the data + + # 1. Create a new dict with additional layout updates for the front-end + extra_layout_updates = {} + + # 1.1. Set autorange to False for each layout item with a specified x-range + xy_matches = self._re_matches(re.compile(r"[xy]axis\d*.range\[\d+]"), cl_k) + for range_change_axis in xy_matches: + axis = range_change_axis.split(".")[0] + extra_layout_updates[f"{axis}.autorange"] = False + layout_traces_list.append(extra_layout_updates) + + # 2. Create the additional trace data for the frond-end + relevant_keys = ["x", "y", "text", "hovertext", "name"] # TODO - marker color + # Note that only updated trace-data will be sent to the client + for idx in updated_trace_indices: + trace = current_graph["data"][idx] + trace_reduced = {k: trace[k] for k in relevant_keys if k in trace} + + # Store the index into the corresponding to-be-sent trace-data so + # the client front-end can know which trace needs to be updated + trace_reduced.update({"index": idx}) + layout_traces_list.append(trace_reduced) + return layout_traces_list + + def register_update_graph_callback( + self, app: dash.Dash, graph_id: str, trace_updater_id: str + ): + """Register the :func:`construct_update_data` method as callback function to + the passed dash-app. + + Parameters + ---------- + app: Union[dash.Dash, JupyterDash] + The app in which the callback will be registered. + graph_id: + The id of the ``dcc.Graph``-component which withholds the to-be resampled + Figure. + trace_updater_id + The id of the ``TraceUpdater`` component. This component is leveraged by + ``FigureResampler`` to efficiently POST the to-be-updated data to the + front-end. + + """ + app.callback( + dash.dependencies.Output(trace_updater_id, "updateData"), + dash.dependencies.Input(graph_id, "relayoutData"), + prevent_initial_call=True, + )(self.construct_update_data) + @staticmethod + def _re_matches(regex: re.Pattern, strings: Iterable[str]) -> List[str]: + """Returns all the items in ``strings`` which regex.match(es) ``regex``.""" + matches = [] + for item in strings: + m = regex.match(item) + if m is not None: + matches.append(m.string) + return sorted(matches) -class FigureResampler(go.Figure): - """""" +class FigureWidgetAggregator(go.FigureWidget): def __init__( self, - figure: go.Figure = go.Figure(), + figure: go.FigureWidget, convert_existing_traces: bool = True, default_n_shown_samples: int = 1000, default_downsampler: AbstractSeriesAggregator = EfficientLTTB(), @@ -47,41 +960,7 @@ def __init__( show_mean_aggregation_size: bool = True, verbose: bool = False, ): - """Instantiate a resampling data mirror. - - Parameters - ---------- - figure: go.Figure - The figure that will be decorated. Can be either an empty figure - (e.g., ``go.Figure()`` or ``make_subplots()``) or an existing figure. - convert_existing_traces: bool - A bool indicating whether the high-frequency traces of the passed ``figure`` - should be resampled, by default True. Hence, when set to False, the - high-frequency traces of the passed ``figure`` will not be resampled. - default_n_shown_samples: int, optional - The default number of samples that will be shown for each trace, - by default 1000.\n - .. note:: - * This can be overridden within the :func:`add_trace` method. - * If a trace withholds fewer datapoints than this parameter, - the data will *not* be aggregated. - default_downsampler: AbstractSeriesDownsampler - An instance which implements the AbstractSeriesDownsampler interface and - will be used as default downsampler, by default ``EfficientLTTB`` with - _interleave_gaps_ set to True. \n - .. note:: This can be overridden within the :func:`add_trace` method. - resampled_trace_prefix_suffix: str, optional - A tuple which contains the ``prefix`` and ``suffix``, respectively, which - will be added to the trace its legend-name when a resampled version of the - trace is shown. By default a bold, orange ``[R]`` is shown as prefix - (no suffix is shown). - show_mean_aggregation_size: bool, optional - Whether the mean aggregation bin size will be added as a suffix to the trace - its legend-name, by default True. - verbose: bool, optional - Whether some verbose messages will be printed or not, by default False. - - """ + assert isinstance(figure, go.FigureWidget) self._hf_data: Dict[str, dict] = {} self._global_n_shown_samples = default_n_shown_samples self._print_verbose = verbose @@ -92,14 +971,10 @@ def __init__( self._global_downsampler = default_downsampler - self._app: JupyterDash | None = None - self._port: int | None = None - self._host: str | None = None - if convert_existing_traces: # call __init__ with the correct layout and set the `_grid_ref` of the # to-be-converted figure - f_ = go.Figure(layout=figure.layout) + f_ = go.FigureWidget(layout=figure.layout) f_._grid_ref = figure._grid_ref super().__init__(f_) @@ -108,6 +983,107 @@ def __init__( else: super().__init__(figure) + self._prev_x_ranges = [] # Contains the previous x-range values + self._relayout_hist = ( + [] + ) # used for logging purposes to save a history of layout changes + + # A list of al xaxis string names e.g., "xaxis", "xaxis2", "xaxis3", .... + self._xaxis_list = self._re_matches(re.compile("xaxis\d*"), self._layout.keys()) + + def update_x_ranges(layout, *x_ranges): + if not len(self._prev_x_ranges): + self._prev_x_ranges = list(x_ranges) + + relayout_dict = {} # variable in which we aim to reconstruct the relayout + # serialize the layout in a new dict object + layout = { + xaxis_str: layout[xaxis_str].to_plotly_json() + for xaxis_str in self._xaxis_list + } + + for i, (xaxis_str, x_range) in enumerate(zip(self._xaxis_list, x_ranges)): + # We also check whether "range" is within the xaxis its layout + if "range" in layout[xaxis_str] and ( + # TODO -> maybe perform an isclose check? + self._prev_x_ranges[i][0] != x_range[0] + or self._prev_x_ranges[i][1] != x_range[1] + ): + # a change took place -> add to the relayout dict + relayout_dict[f"{xaxis_str}.range[0]"] = x_range[0] + relayout_dict[f"{xaxis_str}.range[1]"] = x_range[1] + + # Update the previous x-ranges + self._prev_x_ranges = list(x_ranges) + + if len(relayout_dict): + # Construct the update data + update_data = self.construct_update_data(relayout_dict) + + self._relayout_hist.append(dict(zip(self._x_relayout_keys, x_ranges))) + self._relayout_hist.append(["xaxis-range", len(update_data)]) + self._relayout_hist.append(layout) + self._relayout_hist.append("-" * 30) + + with self.batch_update(): + self.layout.update(update_data[0]) # first update the layout + for updated_trace in update_data[1:]: # then the data + trace_idx = updated_trace.pop("index") + self.data[trace_idx].update(updated_trace) + + def update_spike_ranges(layout, *showspikes): + relayout_dict = {} # variable in which we aim to reconstruct the relayout + # serialize the layout in a new dict object + layout = { + xaxis_str: layout[xaxis_str].to_plotly_json() + for xaxis_str in self._xaxis_list + } + + for xaxis_str, showspike in zip(self._xaxis_list, showspikes): + # Autorange must be set to True and showspikes must be in the + # layout dict + if ( + layout[xaxis_str].get("autorange", False) + and "showspikes" in layout[xaxis_str] + ): + relayout_dict[f"{xaxis_str}.autorange"] = True + relayout_dict[f"{xaxis_str}.showspikes"] = showspike + + if len(relayout_dict): + # Construct the update data + update_data = self.construct_update_data(relayout_dict) + + self._relayout_hist.append(dict(zip(self._showspike_keys, showspikes))) + self._relayout_hist.append(["showspikes", len(update_data)]) + self._relayout_hist.append(layout) + self._relayout_hist.append("-" * 30) + + with self.batch_update(): + # Update the layout + self.layout.update(update_data[0]) + # Also: Remove the showspikes from the layout, otherwise the autorange + # will not work as intended (it will not be triggered again) + # Note: this removal causes a second trigger of this method + # which will go in the "else" part below. + for xaxis_str in self._xaxis_list: + self.layout[xaxis_str].pop("showspikes") + + # Updata the data + for updated_trace in update_data[1:]: + trace_idx = updated_trace.pop("index") + self.data[trace_idx].update(updated_trace) + + else: + self._relayout_hist.append(["showspikes", "intial call or showspikes"]) + self._relayout_hist.append("-" * 40) + + # Assign the the methods to the update class + self._showspike_keys = [f"{xaxis}.showspikes" for xaxis in self._xaxis_list] + self.layout.on_change(update_spike_ranges, *self._showspike_keys) + + self._x_relayout_keys = [f"{xaxis}.range" for xaxis in self._xaxis_list] + self.layout.on_change(update_x_ranges, *self._x_relayout_keys) + def _print(self, *values): """Helper method for printing if ``verbose`` is set to True.""" if self._print_verbose: @@ -601,7 +1577,9 @@ def add_trace( # First add the trace, as each (even the non-hf_data traces), must contain this # key for comparison - trace.uid = str(uuid4()) + uuid = str(uuid4()) + trace.uid = uuid + # print(uuid) hf_x = ( trace["x"] @@ -720,7 +1698,7 @@ def add_trace( # identified by its UUID axis_type = "date" if isinstance(hf_x, pd.DatetimeIndex) else "linear" d = self._global_downsampler if downsampler is None else downsampler - self._hf_data[trace.uid] = { + self._hf_data[uuid] = { "max_n_samples": max_n_samples, "x": hf_x, "y": hf_y, @@ -748,8 +1726,11 @@ def add_trace( # is high-frequency, this would take significant time! # Hence, you first downsample the trace. trace = self._check_update_trace_data(trace) + # print(trace) assert trace is not None - return super().add_trace(trace=trace, **trace_kwargs) + super().add_trace(trace=trace, **trace_kwargs) + self.data[-1].uid = uuid + return else: self._print(f"[i] NOT resampling {trace['name']} - len={n_samples}") trace.x = hf_x @@ -910,7 +1891,7 @@ def construct_update_data(self, relayout_data: dict) -> List[dict]: return layout_traces_list def register_update_graph_callback( - self, app: dash.Dash | JupyterDash, graph_id: str, trace_updater_id: str + self, app: dash.Dash, graph_id: str, trace_updater_id: str ): """Register the :func:`construct_update_data` method as callback function to the passed dash-app. @@ -944,6 +1925,36 @@ def _re_matches(regex: re.Pattern, strings: Iterable[str]) -> List[str]: matches.append(m.string) return sorted(matches) + +class FigureResampler(AbstractFigureAggregator): + def __init__( + self, + figure: go.Figure = go.Figure(), + convert_existing_traces: bool = True, + default_n_shown_samples: int = 1000, + default_downsampler: AbstractSeriesAggregator = EfficientLTTB(), + resampled_trace_prefix_suffix: Tuple[str, str] = ( + '[R] ', + "", + ), + show_mean_aggregation_size: bool = True, + verbose: bool = False, + ): + super().__init__( + figure, + convert_existing_traces, + default_n_shown_samples, + default_downsampler, + resampled_trace_prefix_suffix, + show_mean_aggregation_size, + verbose, + ) + + # The figureAggregator needs a dash app + self._app: JupyterDash | Dash | None = None + self._port: int | None = None + self._host: str | None = None + def show_dash( self, mode=None, @@ -986,7 +1997,7 @@ def show_dash( graph_properties = {} if graph_properties is None else graph_properties assert "config" not in graph_properties.keys() # There is a param for config # 1. Construct the Dash app layout - app = JupyterDash("local_app") + app = JupyterDash("local_app") # TODO -> was jupyterdash app.layout = dash.html.Div( [ dash.dcc.Graph( @@ -1039,3 +2050,22 @@ def stop_server(self, warn: bool = True): + "\t- 'show-dash' method was not called, or \n" + "\t- the dash-server wasn't started with 'show_dash'" ) + + +# class FigureResampler(): +# """Factory class which determines the """ +# def __init__(self, +# figure: go.Figure = go.Figure(), +# convert_existing_traces: bool = True, +# default_n_shown_samples: int = 1000, +# default_downsampler: AbstractSeriesAggregator = EfficientLTTB(), +# resampled_trace_prefix_suffix: Tuple[str, str] = ( +# '[R] ', +# "", +# ), +# show_mean_aggregation_size: bool = True, +# verbose: bool = False): +# return FigureAggregator(figure, convert_existing_traces, +# default_n_shown_samples, default_downsampler, +# resampled_trace_prefix_suffix, +# show_mean_aggregation_size, verbose) From 46e789fd6738314c6144d6461248f5b7aec67881 Mon Sep 17 00:00:00 2001 From: jervdrdo Date: Sun, 1 May 2022 20:23:03 +0200 Subject: [PATCH 02/19] :construction: cleaner OOP --- plotly_resampler/figure_resampler.py | 904 +-------------------------- 1 file changed, 30 insertions(+), 874 deletions(-) diff --git a/plotly_resampler/figure_resampler.py b/plotly_resampler/figure_resampler.py index fa9c535d..b8407d42 100644 --- a/plotly_resampler/figure_resampler.py +++ b/plotly_resampler/figure_resampler.py @@ -11,8 +11,6 @@ from __future__ import annotations -from flask import g - __author__ = "Jonas Van Der Donckt, Jeroen Van Der Donckt, Emiel Deprost" import re @@ -20,7 +18,6 @@ from copy import copy from typing import Dict, Iterable, List, Optional, Tuple, Union from uuid import uuid4 -from itertools import chain import dash import numpy as np @@ -97,10 +94,12 @@ def __init__( self._global_downsampler = default_downsampler + self._figure_class = figure.__class__ + if convert_existing_traces: # call __init__ with the correct layout and set the `_grid_ref` of the # to-be-converted figure - f_ = figure.__class__(layout=figure.layout) + f_ = self._figure_class(layout=figure.layout) f_._grid_ref = figure._grid_ref super().__init__(f_) @@ -602,7 +601,8 @@ def add_trace( # First add the trace, as each (even the non-hf_data traces), must contain this # key for comparison - trace.uid = str(uuid4()) + uuid = str(uuid4) + trace.uid = uuid hf_x = ( trace["x"] @@ -721,7 +721,7 @@ def add_trace( # identified by its UUID axis_type = "date" if isinstance(hf_x, pd.DatetimeIndex) else "linear" d = self._global_downsampler if downsampler is None else downsampler - self._hf_data[trace.uid] = { + self._hf_data[uuid] = { "max_n_samples": max_n_samples, "x": hf_x, "y": hf_y, @@ -750,13 +750,15 @@ def add_trace( # Hence, you first downsample the trace. trace = self._check_update_trace_data(trace) assert trace is not None - return super().add_trace(trace=trace, **trace_kwargs) + super(self._figure_class, self).add_trace(trace=trace, **trace_kwargs) + self.data[-1].uid = uuid + return else: self._print(f"[i] NOT resampling {trace['name']} - len={n_samples}") trace.x = hf_x trace.y = hf_y trace.text = hf_hovertext - return super().add_trace(trace=trace, **trace_kwargs) + return super(self._figure_class, self).add_trace(trace=trace, **trace_kwargs) else: self._print(f"trace {trace['type']} is not a high-frequency trace") @@ -771,7 +773,7 @@ def add_trace( trace["text"] = None trace["hovertext"] = hf_hovertext - return super().add_trace(trace=trace, **trace_kwargs) + return super(self._figure_class, self).add_trace(trace=trace, **trace_kwargs) # def add_traces(*args, **kwargs): # raise NotImplementedError("This functionality is not (yet) supported") @@ -946,7 +948,11 @@ def _re_matches(regex: re.Pattern, strings: Iterable[str]) -> List[str]: return sorted(matches) -class FigureWidgetAggregator(go.FigureWidget): +class _FigureWidgetResamplerM(type(AbstractFigureAggregator), type(go.FigureWidget)): + # MetaClass for the FigureWidgetResampler + pass + +class FigureWidgetResampler(AbstractFigureAggregator, go.FigureWidget, metaclass=_FigureWidgetResamplerM): def __init__( self, figure: go.FigureWidget, @@ -961,27 +967,15 @@ def __init__( verbose: bool = False, ): assert isinstance(figure, go.FigureWidget) - self._hf_data: Dict[str, dict] = {} - self._global_n_shown_samples = default_n_shown_samples - self._print_verbose = verbose - self._show_mean_aggregation_size = show_mean_aggregation_size - - assert len(resampled_trace_prefix_suffix) == 2 - self._prefix, self._suffix = resampled_trace_prefix_suffix - - self._global_downsampler = default_downsampler - - if convert_existing_traces: - # call __init__ with the correct layout and set the `_grid_ref` of the - # to-be-converted figure - f_ = go.FigureWidget(layout=figure.layout) - f_._grid_ref = figure._grid_ref - super().__init__(f_) - - for trace in figure.data: - self.add_trace(trace) - else: - super().__init__(figure) + super().__init__( + figure, + convert_existing_traces, + default_n_shown_samples, + default_downsampler, + resampled_trace_prefix_suffix, + show_mean_aggregation_size, + verbose, + ) self._prev_x_ranges = [] # Contains the previous x-range values self._relayout_hist = ( @@ -1068,7 +1062,7 @@ def update_spike_ranges(layout, *showspikes): for xaxis_str in self._xaxis_list: self.layout[xaxis_str].pop("showspikes") - # Updata the data + # Update the data for updated_trace in update_data[1:]: trace_idx = updated_trace.pop("index") self.data[trace_idx].update(updated_trace) @@ -1079,854 +1073,15 @@ def update_spike_ranges(layout, *showspikes): # Assign the the methods to the update class self._showspike_keys = [f"{xaxis}.showspikes" for xaxis in self._xaxis_list] + # if len(self._showspike_keys) == 0: self._showspike_keys = ['xaxis.showspikes'] self.layout.on_change(update_spike_ranges, *self._showspike_keys) self._x_relayout_keys = [f"{xaxis}.range" for xaxis in self._xaxis_list] + # if len(self._x_relayout_keys) == 0: self._x_relayout_keys = ['xaxis.range'] self.layout.on_change(update_x_ranges, *self._x_relayout_keys) - def _print(self, *values): - """Helper method for printing if ``verbose`` is set to True.""" - if self._print_verbose: - print(*values) - - def _query_hf_data(self, trace: dict) -> Optional[dict]: - """Query the internal ``_hf_data`` attribute and returns a match based on - ``uid``. - - Parameters - ---------- - trace : dict - The trace where we want to find a match for. - - Returns - ------- - Optional[dict] - The ``hf_data``-trace dict if a match is found, else ``None``. - - """ - uid = trace["uid"] - hf_trace_data = self._hf_data.get(uid) - if hf_trace_data is None: - trace_props = { - k: trace[k] for k in set(trace.keys()).difference({"x", "y"}) - } - self._print(f"[W] trace with {trace_props} not found") - return hf_trace_data - - def _get_current_graph(self) -> dict: - """Create an efficient copy of the current graph by omitting the "hovertext", - "x", and "y" properties of each trace. - - Returns - ------- - dict - The current graph dict - - See Also - -------- - https://github.com/plotly/plotly.py/blob/2e7f322c5ea4096ce6efe3b4b9a34d9647a8be9c/packages/python/plotly/plotly/basedatatypes.py#L3278 - """ - return { - "data": [ - { - k: copy(trace[k]) - for k in set(trace.keys()).difference({"x", "y", "hovertext"}) - } - for trace in self._data - ], - "layout": copy(self._layout), - } - - def _check_update_trace_data( - self, - trace: dict, - start=None, - end=None, - ) -> Optional[Union[dict, BaseTraceType]]: - """Check and update the passed ``trace`` its data properties based on the - slice range. - - Note - ---- - This is a pass by reference. The passed trace object will be updated and - returned if found in ``hf_data``. - - Parameters - ---------- - trace : BaseTraceType or dict - - An instances of a trace class from the ``plotly.graph_objects`` (go) - package (e.g, ``go.Scatter``, ``go.Bar``) - - or a dict where: - - - The 'type' property specifies the trace type (e.g. - 'scatter', 'bar', 'area', etc.). If the dict has no 'type' - property then 'scatter' is assumed. - - All remaining properties are passed to the constructor - of the specified trace type. - - start : Union[float, str], optional - The start index for which we want resampled data to be updated to, - by default None, - end : Union[float, str], optional - The end index for which we want the resampled data to be updated to, - by default None - - Returns - ------- - Optional[Union[dict, BaseTraceType]] - If the matching ``hf_series`` is found in ``hf_dict``, an (updated) trace - will be returned, otherwise None. - - Note - ---- - * If ``start`` and ``stop`` are strings, they most likely represent time-strings - * ``start`` and ``stop`` will always be of the same type (float / time-string) - because their underlying axis is the same. - - """ - hf_trace_data = self._query_hf_data(trace) - if hf_trace_data is not None: - axis_type = hf_trace_data["axis_type"] - if axis_type == "date": - start, end = pd.to_datetime(start), pd.to_datetime(end) - hf_series = self._slice_time( - self._to_hf_series(hf_trace_data["x"], hf_trace_data["y"]), - start, - end, - ) - else: - hf_series = self._to_hf_series(hf_trace_data["x"], hf_trace_data["y"]) - start = hf_series.index[0] if start is None else start - end = hf_series.index[-1] if end is None else end - if hf_series.index.is_integer(): - start = round(start) - end = round(end) - - # Search the index-positions - start_idx, end_idx = np.searchsorted(hf_series.index, [start, end]) - hf_series = hf_series.iloc[start_idx:end_idx] - - # Return an invisible, single-point, trace when the sliced hf_series doesn't - # contain any data in the current view - if len(hf_series) == 0: - trace["x"] = [start] - trace["y"] = [None] - trace["hovertext"] = "" - return trace - - # Downsample the data and store it in the trace-fields - downsampler: AbstractSeriesAggregator = hf_trace_data["downsampler"] - s_res: pd.Series = downsampler.aggregate( - hf_series, hf_trace_data["max_n_samples"] - ) - trace["x"] = s_res.index - trace["y"] = s_res.values - # todo -> first draft & not MP safe - - agg_prefix, agg_suffix = ' ~', "" - name: str = trace["name"].split(agg_prefix)[0] - - if len(hf_series) > hf_trace_data["max_n_samples"]: - name = ("" if name.startswith(self._prefix) else self._prefix) + name - name += self._suffix if not name.endswith(self._suffix) else "" - # Add the mean aggregation bin size to the trace name - if self._show_mean_aggregation_size: - agg_mean = np.mean(np.diff(s_res.index.values)) - if isinstance(agg_mean, np.timedelta64): - agg_mean = round_td_str(pd.Timedelta(agg_mean)) - else: - agg_mean = round_number_str(agg_mean) - name += f"{agg_prefix}{agg_mean}{agg_suffix}" - else: - # When not resampled: trim prefix and/or suffix if necessary - if len(self._prefix) and name.startswith(self._prefix): - name = name[len(self._prefix) :] - if len(self._suffix) and trace["name"].endswith(self._suffix): - name = name[: -len(self._suffix)] - trace["name"] = name - - # Check if hovertext also needs to be resampled - hovertext = hf_trace_data.get("hovertext") - if isinstance(hovertext, pd.Series): - # TODO -> this can be optimized - trace["hovertext"] = pd.merge_asof( - s_res, - hovertext, - left_index=True, - right_index=True, - direction="nearest", - )[hovertext.name].values - else: - trace["hovertext"] = hovertext - return trace - else: - self._print("hf_data not found") - return None - - def _check_update_figure_dict( - self, - figure: dict, - start: Optional[Union[float, str]] = None, - stop: Optional[Union[float, str]] = None, - xaxis_filter: str = None, - updated_trace_indices: Optional[List[int]] = None, - ) -> List[int]: - """Check and update the traces within the figure dict. - - hint - ---- - This method will most likely be used within a ``Dash`` callback to resample the - view, based on the configured number of parameters. - - Note - ---- - This is a pass by reference. The passed figure object will be updated. - No new view of this figure will be created, hence no return! - - Parameters - ---------- - figure : dict - The figure dict which will be updated. - start : Union[float, str], optional - The start time for the new resampled data view, by default None. - stop : Union[float, str], optional - The end time for the new resampled data view, by default None. - xaxis_filter: str, optional - Additional trace-update subplot filter, by default None. - updated_trace_indices: List[int], optional - List of trace indices that already have been updated, by default None. - - Returns - ------- - List[int] - A list of indices withholding the trace-data-array-index from the of data - modalities which are updated. - - """ - xaxis_filter_short = None - if xaxis_filter is not None: - xaxis_filter_short = "x" + xaxis_filter.lstrip("xaxis") - - if updated_trace_indices is None: - updated_trace_indices = [] - - for idx, trace in enumerate(figure["data"]): - # We skip when the trace-idx already has been updated. - if idx in updated_trace_indices: - continue - - if xaxis_filter is not None: - # the x-anchor of the trace is stored in the layout data - if trace.get("yaxis") is None: - # no yaxis -> we make the assumption that yaxis = xaxis_filter_short - y_axis = "y" + xaxis_filter[1:] - else: - y_axis = "yaxis" + trace.get("yaxis")[1:] - - # Next to the x-anchor, we also fetch the xaxis which matches the - # current trace (i.e. if this value is not None, the axis shares the - # x-axis with one or more traces). - # This is relevant when e.g. fig.update_traces(xaxis='x...') was called. - x_anchor_trace = figure["layout"].get(y_axis, {}).get("anchor") - if x_anchor_trace is not None: - xaxis_matches = ( - figure["layout"] - .get("xaxis" + x_anchor_trace.lstrip("x"), {}) - .get("matches") - ) - else: - xaxis_matches = figure["layout"].get("xaxis", {}).get("matches") - - # print( - # f"x_anchor: {x_anchor_trace} - xaxis_filter: {xaxis_filter} ", - # f"- xaxis_matches: {xaxis_matches}" - # ) - - # We skip when: - # * the change was made on the first row and the trace its anchor is not - # in [None, 'x'] and the matching (a.k.a. shared) xaxis is not equal - # to the xaxis filter argument. - # -> why None: traces without row/col argument and stand on first row - # and do not have the anchor property (hence the DICT.get() method) - # * x_axis_filter_short not in [x_anchor or xaxis matches] for - # NON first rows - if ( - xaxis_filter_short == "x" - and ( - x_anchor_trace not in [None, "x"] - and xaxis_matches != xaxis_filter_short - ) - ) or ( - xaxis_filter_short != "x" - and (xaxis_filter_short not in [x_anchor_trace, xaxis_matches]) - ): - continue - - # If we managed to find and update the trace, it will return the trace - # and thus not None. - updated_trace = self._check_update_trace_data(trace, start=start, end=stop) - if updated_trace is not None: - updated_trace_indices.append(idx) - return updated_trace_indices - - @staticmethod - def _slice_time( - hf_series: pd.Series, - t_start: Optional[pd.Timestamp] = None, - t_stop: Optional[pd.Timestamp] = None, - ) -> pd.Series: - """Slice the time-indexed ``hf_series`` for the passed pd.Timestamps. - - Note - ---- - This returns a **view** of ``hf_series``! - - Parameters - ---------- - hf_series: pd.Series - The **datetime-indexed** series, which will be sliced. - t_start: pd.Timestamp, optional - The lower-time-bound of the slice, if set to None, no lower-bound threshold - will be applied, by default None. - t_stop: pd.Timestamp, optional - The upper time-bound of the slice, if set to None, no upper-bound threshold - will be applied, by default None. - - Returns - ------- - pd.Series - The sliced **view** of the series. - - """ - - def to_same_tz( - ts: Union[pd.Timestamp, None], reference_tz=hf_series.index.tz - ) -> Union[pd.Timestamp, None]: - """Adjust `ts` its timezone to the `reference_tz`.""" - if ts is None: - return None - elif reference_tz is not None: - if ts.tz is not None: - assert ts.tz.zone == reference_tz.zone - return ts - else: # localize -> time remains the same - return ts.tz_localize(reference_tz) - elif reference_tz is None and ts.tz is not None: - return ts.tz_localize(None) - return ts - - if t_start is not None and t_stop is not None: - assert t_start.tz == t_stop.tz - - return hf_series[to_same_tz(t_start) : to_same_tz(t_stop)] - - @property - def hf_data(self): - """Property to adjust the `data` component of the current graph - - .. note:: - The user has full responisbility to adjust ``hf_data`` properly. - - - Example: - >>> fig = FigureResampler(go.Figure()) - >>> fig.add_trace(...) - >>> fig.hf_data[-1]["y"] = - s ** 2 # adjust the y-property of the trace added above - >>> fig._hf_data - [ - { - 'max_n_samples': 1000, - 'x': RangeIndex(start=0, stop=11000000, step=1), - 'y': array([-0.01339909, 0.01390696,, ..., 0.25051913, 0.55876513]), - 'axis_type': 'linear', - 'downsampler': , - 'hovertext': None - }, - ] - """ - return list(self._hf_data.values()) - - @staticmethod - def _to_hf_series(x: np.ndarray, y: np.ndarray) -> pd.Series: - """Construct the hf-series. - - Parameters - ---------- - x : np.ndarray - The hf_series index - y : np.ndarray - The hf_series values - - Returns - ------- - pd.Series - The constructed hf_series - """ - return pd.Series( - data=y, - index=x, - copy=False, - name="data", - dtype="category" if y.dtype.type == np.str_ else y.dtype, - ) - - def add_trace( - self, - trace: Union[BaseTraceType, dict], - max_n_samples: int = None, - downsampler: AbstractSeriesAggregator = None, - limit_to_view: bool = False, - # Use these if you want some speedups (and are working with really large data) - hf_x: Iterable = None, - hf_y: Iterable = None, - hf_hovertext: Union[str, Iterable] = None, - **trace_kwargs, - ): - """Add a trace to the figure. - - Parameters - ---------- - trace : BaseTraceType or dict - Either: - - - An instances of a trace class from the ``plotly.graph_objects`` (go) - package (e.g., ``go.Scatter``, ``go.Bar``) - - or a dict where: - - - The type property specifies the trace type (e.g. scatter, bar, - area, etc.). If the dict has no 'type' property then scatter is - assumed. - - All remaining properties are passed to the constructor - of the specified trace type. - max_n_samples : int, optional - The maximum number of samples that will be shown by the trace.\n - .. note:: - If this variable is not set; ``_global_n_shown_samples`` will be used. - downsampler: AbstractSeriesDownsampler, optional - The abstract series downsampler method.\n - .. note:: - If this variable is not set, ``_global_downsampler`` will be used. - limit_to_view: boolean, optional - If set to True the trace's datapoints will be cut to the corresponding - front-end view, even if the total number of samples is lower than - ``max_n_samples``, By default False. - hf_x: Iterable, optional - The original high frequency series positions, can be either a time-series or - an increasing, numerical index. If set, this has priority over the trace its - data. - hf_y: Iterable, optional - The original high frequency values. If set, this has priority over the - trace its data. - hf_hovertext: Iterable, optional - The original high frequency hovertext. If set, this has priority over the - trace its ``text`` or ``hovertext`` argument. - **trace_kwargs: dict - Additional trace related keyword arguments. - e.g.: row=.., col=..., secondary_y=... - - .. seealso:: - `Figure.add_trace `_ docs. - - Returns - ------- - BaseFigure - The Figure on which ``add_trace`` was called on; i.e. self. - - Note - ---- - Constructing traces with **very large data amounts** really takes some time. - To speed this up; use this :func:`add_trace` method and - - 1. Create a trace with no data (empty lists) - 2. pass the high frequency data to this method using the ``hf_x`` and ``hf_y`` - parameters. - - See the example below: - - >>> from plotly.subplots import make_subplots - >>> s = pd.Series() # a high-frequency series, with more than 1e7 samples - >>> fig = FigureResampler(go.Figure()) - >>> fig.add_trace(go.Scattergl(x=[], y=[], ...), hf_x=s.index, hf_y=s) - - .. todo:: - * explain why adding x and y to a trace is so slow - * check and simplify the example above - - Tip - --- - * If you **do not want to downsample** your data, set ``max_n_samples`` to the - the number of datapoints of your trace! - - Attention - --------- - * The ``NaN`` values in either ``hf_y`` or ``trace.y`` will be omitted! We do - not allow ``NaN`` values in ``hf_x`` or ``trace.x``. - * ``hf_x``, ``hf_y``, and ``hf_hovertext`` are useful when you deal with large - amounts of data (as it can increase the speed of this add_trace() method with - ~30%). These arguments have priority over the trace's data and (hover)text - attributes. - * Low-frequency time-series data, i.e. traces that are not resampled, can hinder - the the automatic-zooming (y-scaling) as these will not be stored in the - back-end and thus not be scaled to the view. - To circumvent this, the ``limit_to_view`` argument can be set, resulting in - also storing the low-frequency series in the back-end. - - """ - if max_n_samples is None: - max_n_samples = self._global_n_shown_samples - - # First add the trace, as each (even the non-hf_data traces), must contain this - # key for comparison - uuid = str(uuid4()) - trace.uid = uuid - # print(uuid) - - hf_x = ( - trace["x"] - if hasattr(trace, "x") and hf_x is None - else hf_x.values - if isinstance(hf_x, pd.Series) - else hf_x - ) - if isinstance(hf_x, tuple): - hf_x = list(hf_x) - - hf_y = ( - trace["y"] - if hasattr(trace, "y") and hf_y is None - else hf_y.values - if isinstance(hf_y, pd.Series) - else hf_y - ) - hf_y = np.asarray(hf_y) - - # Note: "hovertext" takes precedence over "text" - hf_hovertext = ( - hf_hovertext - if hf_hovertext is not None - else trace["hovertext"] - if hasattr(trace, "hovertext") and trace["hovertext"] is not None - else trace["text"] - if hasattr(trace, "text") - else None - ) - - high_frequency_traces = ["scatter", "scattergl"] - if trace["type"].lower() in high_frequency_traces: - if hf_x is None: # if no data as x or hf_x is passed - if hf_y.ndim != 0: # if hf_y is an array - hf_x = np.arange(len(hf_y)) - else: # if no data as y or hf_y is passed - hf_x = np.asarray(None) - - assert hf_y.ndim == np.ndim(hf_x), ( - "plotly-resampler requires scatter data " - "(i.e., x and y, or hf_x and hf_y) to have the same dimensionality!" - ) - # When the x or y of a trace has more than 1 dimension, it is not at all - # straightforward how it should be resampled. - assert hf_y.ndim <= 1 and np.ndim(hf_x) <= 1, ( - "plotly-resampler requires scatter data " - "(i.e., x and y, or hf_x and hf_y) to be <= 1 dimensional!" - ) - - # Make sure to set the text-attribute to None as the default plotly behavior - # for these high-dimensional traces (scatters) is that text will be shown in - # hovertext and not in on-graph texts (as is the case with bar-charts) - trace["text"] = None - - # Note: this also converts hf_hovertext to a np.ndarray - if isinstance(hf_hovertext, (list, np.ndarray, pd.Series)): - hf_hovertext = np.asarray(hf_hovertext) - - # Remove NaNs for efficiency (storing less meaningless data) - # NaNs introduce gaps between enclosing non-NaN data points & might distort - # the resampling algorithms - if pd.isna(hf_y).any(): - not_nan_mask = ~pd.isna(hf_y) - hf_x = hf_x[not_nan_mask] - hf_y = hf_y[not_nan_mask] - if isinstance(hf_hovertext, np.ndarray): - hf_hovertext = hf_hovertext[not_nan_mask] - - # If the categorical or string-like hf_y data is of type object (happens - # when y argument is used for the trace constructor instead of hf_y), we - # transform it to type string as such it will be sent as categorical data - # to the downsampling algorithm - if hf_y.dtype == "object": - hf_y = hf_y.astype("str") - - # orjson encoding doesn't like to encode with uint8 & uint16 dtype - if str(hf_y.dtype) in ["uint8", "uint16"]: - hf_y = hf_y.astype("uint32") - - assert len(hf_x) == len(hf_y), "x and y have different length!" - - # Convert the hovertext to a pd.Series if it's now a np.ndarray - # Note: The size of hovertext must be the same size as hf_x otherwise a - # ValueError will be thrown - if isinstance(hf_hovertext, np.ndarray): - hf_hovertext = pd.Series( - data=hf_hovertext, index=hf_x, copy=False, name="hovertext" - ) - - n_samples = len(hf_x) - # These traces will determine the autoscale RANGE! - # -> so also store when `limit_to_view` is set. - if n_samples > max_n_samples or limit_to_view: - self._print( - f"\t[i] DOWNSAMPLE {trace['name']}\t{n_samples}->{max_n_samples}" - ) - - # We will re-create this each time as hf_x and hf_y withholds - # high-frequency data - # index = pd.Index(hf_x, copy=False, name="timestamp") - hf_series = self._to_hf_series(x=hf_x, y=hf_y) - - # Checking this now avoids less interpretable `KeyError` when resampling - assert hf_series.index.is_monotonic_increasing - - # As we support prefix-suffixing of downsampled data, we assure that - # each trace has a name - # https://github.com/plotly/plotly.py/blob/ce0ed07d872c487698bde9d52e1f1aadf17aa65f/packages/python/plotly/plotly/basedatatypes.py#L539 - # The link above indicates that the trace index is derived from `data` - if trace.name is None: - trace.name = f"trace {len(self.data)}" - - # Determine (1) the axis type and (2) the downsampler instance - # & (3) store a hf_data entry for the corresponding trace, - # identified by its UUID - axis_type = "date" if isinstance(hf_x, pd.DatetimeIndex) else "linear" - d = self._global_downsampler if downsampler is None else downsampler - self._hf_data[uuid] = { - "max_n_samples": max_n_samples, - "x": hf_x, - "y": hf_y, - "axis_type": axis_type, - "downsampler": d, - "hovertext": hf_hovertext, - } - - # Before we update the trace, we create a new pointer to that trace in - # which the downsampled data will be stored. This way, the original - # data of the trace to this `add_trace` method will not be altered. - # We copy (by reference) all the non-data properties of the trace in - # the new trace. - if not isinstance(trace, dict): - trace = trace._props - trace = { - k: trace[k] - for k in set(trace.keys()).difference( - {"text", "hovertext", "x", "y"} - ) - } - - # NOTE: - # If all the raw data needs to be sent to the javascript, and the trace - # is high-frequency, this would take significant time! - # Hence, you first downsample the trace. - trace = self._check_update_trace_data(trace) - # print(trace) - assert trace is not None - super().add_trace(trace=trace, **trace_kwargs) - self.data[-1].uid = uuid - return - else: - self._print(f"[i] NOT resampling {trace['name']} - len={n_samples}") - trace.x = hf_x - trace.y = hf_y - trace.text = hf_hovertext - return super().add_trace(trace=trace, **trace_kwargs) - else: - self._print(f"trace {trace['type']} is not a high-frequency trace") - - # hf_x and hf_y have priority over the traces' data - if hasattr(trace, "x"): - trace["x"] = hf_x - - if hasattr(trace, "y"): - trace["y"] = hf_y - - if hasattr(trace, "text") and hasattr(trace, "hovertext"): - trace["text"] = None - trace["hovertext"] = hf_hovertext - - return super().add_trace(trace=trace, **trace_kwargs) - - # def add_traces(*args, **kwargs): - # raise NotImplementedError("This functionality is not (yet) supported") - - def _clear_figure(self): - """Clear the current figure object it's data and layout.""" - self._hf_data = {} - self.data = [] - self.layout = {} - - def replace(self, figure: go.Figure, convert_existing_traces: bool = True): - """Replace the current figure layout with the passed figure object. - - Parameters - ---------- - figure: go.Figure - The figure object which will replace the existing figure. - convert_existing_traces: bool, Optional - A bool indicating whether the traces of the passed ``figure`` should be - resampled, by default True. - - """ - self._clear_figure() - self.__init__( - figure=figure, - convert_existing_traces=convert_existing_traces, - default_n_shown_samples=self._global_n_shown_samples, - default_downsampler=self._global_downsampler, - resampled_trace_prefix_suffix=(self._prefix, self._suffix), - ) - - def construct_update_data(self, relayout_data: dict) -> List[dict]: - """Construct the to-be-updated front-end data, based on the layout change. - - Attention - --------- - This method is tightly coupled with Dash app callbacks. It takes the front-end - figure its ``relayoutData`` as input and returns the data which needs to be - sent tot the ``TraceUpdater`` its ``updateData`` property for that corresponding - graph. - - Parameters - ---------- - relayout_data: dict - A dict containing the ``relayout``-data (a.k.a. changed layout data) of - the corresponding front-end graph. - - Returns - ------- - List[dict]: - A list of dicts, where each dict-item is a representation of a trace its - *data* properties which are affected by the front-end layout change. |br| - In other words, only traces which need to be updated will be sent to the - front-end. Additionally, each trace-dict withholds the *index* of its - corresponding position in the ``figure[data]`` array with the ``index``-key - in each dict. - - """ - current_graph = self._get_current_graph() - updated_trace_indices, cl_k = [], [] - if relayout_data: - self._print("-" * 100 + "\n", "changed layout", relayout_data) - - cl_k = relayout_data.keys() - - # ------------------ HF DATA aggregation --------------------- - # 1. Base case - there is a x-range specified in the front-end - start_matches = self._re_matches(re.compile(r"xaxis\d*.range\[0]"), cl_k) - stop_matches = self._re_matches(re.compile(r"xaxis\d*.range\[1]"), cl_k) - if len(start_matches) and len(stop_matches): - for t_start_key, t_stop_key in zip(start_matches, stop_matches): - # Check if the xaxis part of xaxis.[0-1] matches - xaxis = t_start_key.split(".")[0] - assert xaxis == t_stop_key.split(".")[0] - # -> we want to copy the layout on the back-end - updated_trace_indices = self._check_update_figure_dict( - current_graph, - start=relayout_data[t_start_key], - stop=relayout_data[t_stop_key], - xaxis_filter=xaxis, - updated_trace_indices=updated_trace_indices, - ) - - # 2. The user clicked on either autorange | reset axes - autorange_matches = self._re_matches( - re.compile(r"xaxis\d*.autorange"), cl_k - ) - spike_matches = self._re_matches(re.compile(r"xaxis\d*.showspikes"), cl_k) - # 2.1 Reset-axes -> autorange & reset to the global data view - if len(autorange_matches) and len(spike_matches): - for autorange_key in autorange_matches: - if relayout_data[autorange_key]: - xaxis = autorange_key.split(".")[0] - updated_trace_indices = self._check_update_figure_dict( - current_graph, - xaxis_filter=xaxis, - updated_trace_indices=updated_trace_indices, - ) - # 2.1. Autorange -> do nothing, the autorange will be applied on the - # current front-end view - elif len(autorange_matches) and not len(spike_matches): - # PreventUpdate returns a 204 status code response on the - # relayout post request - raise dash.exceptions.PreventUpdate() - - # If we do not have any traces to be updated, we will return an empty - # request response - if len(updated_trace_indices) == 0: - # PreventUpdate returns a 204 status-code response on the relayout post - # request - raise dash.exceptions.PreventUpdate() - - # -------------------- construct callback data -------------------------- - layout_traces_list: List[dict] = [] # the data - - # 1. Create a new dict with additional layout updates for the front-end - extra_layout_updates = {} - - # 1.1. Set autorange to False for each layout item with a specified x-range - xy_matches = self._re_matches(re.compile(r"[xy]axis\d*.range\[\d+]"), cl_k) - for range_change_axis in xy_matches: - axis = range_change_axis.split(".")[0] - extra_layout_updates[f"{axis}.autorange"] = False - layout_traces_list.append(extra_layout_updates) - - # 2. Create the additional trace data for the frond-end - relevant_keys = ["x", "y", "text", "hovertext", "name"] # TODO - marker color - # Note that only updated trace-data will be sent to the client - for idx in updated_trace_indices: - trace = current_graph["data"][idx] - trace_reduced = {k: trace[k] for k in relevant_keys if k in trace} - - # Store the index into the corresponding to-be-sent trace-data so - # the client front-end can know which trace needs to be updated - trace_reduced.update({"index": idx}) - layout_traces_list.append(trace_reduced) - return layout_traces_list - - def register_update_graph_callback( - self, app: dash.Dash, graph_id: str, trace_updater_id: str - ): - """Register the :func:`construct_update_data` method as callback function to - the passed dash-app. - - Parameters - ---------- - app: Union[dash.Dash, JupyterDash] - The app in which the callback will be registered. - graph_id: - The id of the ``dcc.Graph``-component which withholds the to-be resampled - Figure. - trace_updater_id - The id of the ``TraceUpdater`` component. This component is leveraged by - ``FigureResampler`` to efficiently POST the to-be-updated data to the - front-end. - - """ - app.callback( - dash.dependencies.Output(trace_updater_id, "updateData"), - dash.dependencies.Input(graph_id, "relayoutData"), - prevent_initial_call=True, - )(self.construct_update_data) - - @staticmethod - def _re_matches(regex: re.Pattern, strings: Iterable[str]) -> List[str]: - """Returns all the items in ``strings`` which regex.match(es) ``regex``.""" - matches = [] - for item in strings: - m = regex.match(item) - if m is not None: - matches.append(m.string) - return sorted(matches) - -class FigureResampler(AbstractFigureAggregator): +class FigureResampler(AbstractFigureAggregator, go.Figure): def __init__( self, figure: go.Figure = go.Figure(), @@ -1940,6 +1095,7 @@ def __init__( show_mean_aggregation_size: bool = True, verbose: bool = False, ): + assert isinstance(figure, go.Figure) super().__init__( figure, convert_existing_traces, From ee9a85563cf3d08e67b6d9d0fcfc3c237774bbee Mon Sep 17 00:00:00 2001 From: jervdrdo Date: Sun, 1 May 2022 22:34:17 +0200 Subject: [PATCH 03/19] :bug: add paranthesis to uuid4 call :see_no_evil: --- plotly_resampler/figure_resampler.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/plotly_resampler/figure_resampler.py b/plotly_resampler/figure_resampler.py index b8407d42..15ef7ede 100644 --- a/plotly_resampler/figure_resampler.py +++ b/plotly_resampler/figure_resampler.py @@ -601,7 +601,7 @@ def add_trace( # First add the trace, as each (even the non-hf_data traces), must contain this # key for comparison - uuid = str(uuid4) + uuid = str(uuid4()) trace.uid = uuid hf_x = ( @@ -758,7 +758,9 @@ def add_trace( trace.x = hf_x trace.y = hf_y trace.text = hf_hovertext - return super(self._figure_class, self).add_trace(trace=trace, **trace_kwargs) + return super(self._figure_class, self).add_trace( + trace=trace, **trace_kwargs + ) else: self._print(f"trace {trace['type']} is not a high-frequency trace") @@ -773,7 +775,9 @@ def add_trace( trace["text"] = None trace["hovertext"] = hf_hovertext - return super(self._figure_class, self).add_trace(trace=trace, **trace_kwargs) + return super(self._figure_class, self).add_trace( + trace=trace, **trace_kwargs + ) # def add_traces(*args, **kwargs): # raise NotImplementedError("This functionality is not (yet) supported") @@ -952,7 +956,10 @@ class _FigureWidgetResamplerM(type(AbstractFigureAggregator), type(go.FigureWidg # MetaClass for the FigureWidgetResampler pass -class FigureWidgetResampler(AbstractFigureAggregator, go.FigureWidget, metaclass=_FigureWidgetResamplerM): + +class FigureWidgetResampler( + AbstractFigureAggregator, go.FigureWidget, metaclass=_FigureWidgetResamplerM +): def __init__( self, figure: go.FigureWidget, @@ -990,7 +997,7 @@ def update_x_ranges(layout, *x_ranges): self._prev_x_ranges = list(x_ranges) relayout_dict = {} # variable in which we aim to reconstruct the relayout - # serialize the layout in a new dict object + # serialize the layout in a new dict object layout = { xaxis_str: layout[xaxis_str].to_plotly_json() for xaxis_str in self._xaxis_list @@ -1027,14 +1034,14 @@ def update_x_ranges(layout, *x_ranges): def update_spike_ranges(layout, *showspikes): relayout_dict = {} # variable in which we aim to reconstruct the relayout - # serialize the layout in a new dict object + # serialize the layout in a new dict object layout = { xaxis_str: layout[xaxis_str].to_plotly_json() for xaxis_str in self._xaxis_list } for xaxis_str, showspike in zip(self._xaxis_list, showspikes): - # Autorange must be set to True and showspikes must be in the + # Autorange must be set to True and showspikes must be in the # layout dict if ( layout[xaxis_str].get("autorange", False) From 87a22e6721e6ec28d7cd1d5a92b77a30e1419fea Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Mon, 2 May 2022 14:47:35 +0200 Subject: [PATCH 04/19] :sparkles: --- plotly_resampler/figure_resampler.py | 209 ++++++++++++++------------- 1 file changed, 111 insertions(+), 98 deletions(-) diff --git a/plotly_resampler/figure_resampler.py b/plotly_resampler/figure_resampler.py index 15ef7ede..f3775163 100644 --- a/plotly_resampler/figure_resampler.py +++ b/plotly_resampler/figure_resampler.py @@ -960,6 +960,7 @@ class _FigureWidgetResamplerM(type(AbstractFigureAggregator), type(go.FigureWidg class FigureWidgetResampler( AbstractFigureAggregator, go.FigureWidget, metaclass=_FigureWidgetResamplerM ): + """Data aggregation functionality wrapper for ``go.FigureWidgets``.""" def __init__( self, figure: go.FigureWidget, @@ -985,110 +986,141 @@ def __init__( ) self._prev_x_ranges = [] # Contains the previous x-range values - self._relayout_hist = ( - [] - ) # used for logging purposes to save a history of layout changes + if verbose: + # used for logging purposes to save a history of layout changes + self._relayout_hist = [] # A list of al xaxis string names e.g., "xaxis", "xaxis2", "xaxis3", .... self._xaxis_list = self._re_matches(re.compile("xaxis\d*"), self._layout.keys()) - def update_x_ranges(layout, *x_ranges): - if not len(self._prev_x_ranges): - self._prev_x_ranges = list(x_ranges) + # Assign the the methods to the update class + self._showspike_keys = [f"{xaxis}.showspikes" for xaxis in self._xaxis_list] + self.layout.on_change(self._update_spike_ranges, *self._showspike_keys) - relayout_dict = {} # variable in which we aim to reconstruct the relayout - # serialize the layout in a new dict object - layout = { - xaxis_str: layout[xaxis_str].to_plotly_json() - for xaxis_str in self._xaxis_list - } + self._x_relayout_keys = [f"{xaxis}.range" for xaxis in self._xaxis_list] + self.layout.on_change(self._update_x_ranges, *self._x_relayout_keys) - for i, (xaxis_str, x_range) in enumerate(zip(self._xaxis_list, x_ranges)): - # We also check whether "range" is within the xaxis its layout - if "range" in layout[xaxis_str] and ( - # TODO -> maybe perform an isclose check? - self._prev_x_ranges[i][0] != x_range[0] - or self._prev_x_ranges[i][1] != x_range[1] - ): - # a change took place -> add to the relayout dict - relayout_dict[f"{xaxis_str}.range[0]"] = x_range[0] - relayout_dict[f"{xaxis_str}.range[1]"] = x_range[1] + def _update_x_ranges(self, layout, *x_ranges): + """Update the the go.Figure data based on changed x-ranges. - # Update the previous x-ranges + Parameters + ---------- + layout : go.Layout + The figure's (i.e, self) layout object. Remark that this is a reference, + so if we change self.layout (same object reference), this object will change. + *x_ranges: iterable + A iterable list of current x-ranges, where each x-range is a tuple of two + items, indicating the current/new (if changed) left-right x-range, + respectively. + """ + if not len(self._prev_x_ranges): self._prev_x_ranges = list(x_ranges) - if len(relayout_dict): - # Construct the update data - update_data = self.construct_update_data(relayout_dict) + relayout_dict = {} # variable in which we aim to reconstruct the relayout + # serialize the layout in a new dict object + layout = { + xaxis_str: layout[xaxis_str].to_plotly_json() + for xaxis_str in self._xaxis_list + } + for i, (xaxis_str, x_range) in enumerate(zip(self._xaxis_list, x_ranges)): + # We also check whether "range" is within the xaxis its layout otherwise + # It is most-likely an autorange check + if "range" in layout[xaxis_str] and ( + self._prev_x_ranges[i][0] != x_range[0] + or self._prev_x_ranges[i][1] != x_range[1] + ): + # a change took place -> add to the relayout dict + relayout_dict[f"{xaxis_str}.range[0]"] = x_range[0] + relayout_dict[f"{xaxis_str}.range[1]"] = x_range[1] + + # Update the previous x-ranges + self._prev_x_ranges = list(x_ranges) + + if len(relayout_dict): + # Construct the update data + update_data = self.construct_update_data(relayout_dict) + + if self._print_verbose: self._relayout_hist.append(dict(zip(self._x_relayout_keys, x_ranges))) - self._relayout_hist.append(["xaxis-range", len(update_data)]) self._relayout_hist.append(layout) + self._relayout_hist.append(["xaxis-range", len(update_data)]) self._relayout_hist.append("-" * 30) - with self.batch_update(): - self.layout.update(update_data[0]) # first update the layout - for updated_trace in update_data[1:]: # then the data - trace_idx = updated_trace.pop("index") - self.data[trace_idx].update(updated_trace) - - def update_spike_ranges(layout, *showspikes): - relayout_dict = {} # variable in which we aim to reconstruct the relayout - # serialize the layout in a new dict object - layout = { - xaxis_str: layout[xaxis_str].to_plotly_json() - for xaxis_str in self._xaxis_list - } + with self.batch_update(): + # First update the layout (first item of update_data) + self.layout.update(update_data[0]) - for xaxis_str, showspike in zip(self._xaxis_list, showspikes): - # Autorange must be set to True and showspikes must be in the - # layout dict - if ( - layout[xaxis_str].get("autorange", False) - and "showspikes" in layout[xaxis_str] - ): - relayout_dict[f"{xaxis_str}.autorange"] = True - relayout_dict[f"{xaxis_str}.showspikes"] = showspike + # Then update the data + for updated_trace in update_data[1:]: + trace_idx = updated_trace.pop("index") + self.data[trace_idx].update(updated_trace) - if len(relayout_dict): - # Construct the update data - update_data = self.construct_update_data(relayout_dict) + def _update_spike_ranges(self, layout, *showspikes): + """Update the go.Figure based on the changed spike-ranges. - self._relayout_hist.append(dict(zip(self._showspike_keys, showspikes))) - self._relayout_hist.append(["showspikes", len(update_data)]) + Parameters + ---------- + layout : go.Layout + The figure's (i.e, self) layout object. Remark that this is a reference, + so if we change self.layout (same object reference), this object will change. + *showspikes: iterable + A iterable where each item is a bool, indicating whether showspikes is set + to true/false for the corresponding xaxis in ``self._xaxis_list``. + """ + relayout_dict = {} # variable in which we aim to reconstruct the relayout + # serialize the layout in a new dict object + layout = { + xaxis_str: layout[xaxis_str].to_plotly_json() + for xaxis_str in self._xaxis_list + } + + for xaxis_str, showspike in zip(self._xaxis_list, showspikes): + if ( + # autorange key must be set to True + layout[xaxis_str].get("autorange", False) + # we only perform updates for traces which have 'range' property, + # as we do need to reconstruct the update-data for these traces + and layout[xaxis_str].get("range", None) is not None + # showspikes must also be present + and "showspikes" in layout[xaxis_str] + ): + relayout_dict[f"{xaxis_str}.autorange"] = True + relayout_dict[f"{xaxis_str}.showspikes"] = showspike + + if len(relayout_dict): + # Construct the update data + update_data = self.construct_update_data(relayout_dict) + if self._print_verbose: + # self._relayout_hist.append(dict(zip(self._showspike_keys, showspikes))) self._relayout_hist.append(layout) + self._relayout_hist.append(["showspikes", len(update_data)]) self._relayout_hist.append("-" * 30) - with self.batch_update(): - # Update the layout - self.layout.update(update_data[0]) - # Also: Remove the showspikes from the layout, otherwise the autorange - # will not work as intended (it will not be triggered again) - # Note: this removal causes a second trigger of this method - # which will go in the "else" part below. - for xaxis_str in self._xaxis_list: - self.layout[xaxis_str].pop("showspikes") - - # Update the data - for updated_trace in update_data[1:]: - trace_idx = updated_trace.pop("index") - self.data[trace_idx].update(updated_trace) + with self.batch_update(): + # First update the layout (first item of update_data) + self.layout.update(update_data[0]) - else: - self._relayout_hist.append(["showspikes", "intial call or showspikes"]) - self._relayout_hist.append("-" * 40) + # Also: Remove the showspikes from the layout, otherwise the autorange + # will not work as intended (it will not be triggered again) + # Note: this removal causes a second trigger of this method + # which will go in the "else" part below. + for xaxis_str in self._xaxis_list: + self.layout[xaxis_str].pop("showspikes") - # Assign the the methods to the update class - self._showspike_keys = [f"{xaxis}.showspikes" for xaxis in self._xaxis_list] - # if len(self._showspike_keys) == 0: self._showspike_keys = ['xaxis.showspikes'] - self.layout.on_change(update_spike_ranges, *self._showspike_keys) + # Then, update the data + for updated_trace in update_data[1:]: + trace_idx = updated_trace.pop("index") + self.data[trace_idx].update(updated_trace) + + elif self._print_verbose: + self._relayout_hist.append(["showspikes", "initial call or showspikes"]) + self._relayout_hist.append("-" * 40) - self._x_relayout_keys = [f"{xaxis}.range" for xaxis in self._xaxis_list] - # if len(self._x_relayout_keys) == 0: self._x_relayout_keys = ['xaxis.range'] - self.layout.on_change(update_x_ranges, *self._x_relayout_keys) class FigureResampler(AbstractFigureAggregator, go.Figure): + """Data aggregation functionality for Figures.""" def __init__( self, figure: go.Figure = go.Figure(), @@ -1212,23 +1244,4 @@ def stop_server(self, warn: bool = True): "Could not stop the server, either the \n" + "\t- 'show-dash' method was not called, or \n" + "\t- the dash-server wasn't started with 'show_dash'" - ) - - -# class FigureResampler(): -# """Factory class which determines the """ -# def __init__(self, -# figure: go.Figure = go.Figure(), -# convert_existing_traces: bool = True, -# default_n_shown_samples: int = 1000, -# default_downsampler: AbstractSeriesAggregator = EfficientLTTB(), -# resampled_trace_prefix_suffix: Tuple[str, str] = ( -# '[R] ', -# "", -# ), -# show_mean_aggregation_size: bool = True, -# verbose: bool = False): -# return FigureAggregator(figure, convert_existing_traces, -# default_n_shown_samples, default_downsampler, -# resampled_trace_prefix_suffix, -# show_mean_aggregation_size, verbose) + ) \ No newline at end of file From 76d7ad0aee4916143dd1299012f057bb4fd09df7 Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Mon, 2 May 2022 17:00:56 +0200 Subject: [PATCH 05/19] :goggles: --- plotly_resampler/figure_resampler.py | 68 ++++++++++++++++------------ 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/plotly_resampler/figure_resampler.py b/plotly_resampler/figure_resampler.py index f3775163..e05aba4f 100644 --- a/plotly_resampler/figure_resampler.py +++ b/plotly_resampler/figure_resampler.py @@ -23,8 +23,11 @@ import numpy as np import pandas as pd import plotly.graph_objects as go -from jupyter_dash import JupyterDash -from dash import Dash + +try: + from jupyter_dash import JupyterDash +except: + from dash import Dash from plotly.basedatatypes import BaseTraceType, BaseFigure from trace_updater import TraceUpdater @@ -456,7 +459,7 @@ def hf_data(self): >>> fig = FigureResampler(go.Figure()) >>> fig.add_trace(...) >>> fig.hf_data[-1]["y"] = - s ** 2 # adjust the y-property of the trace added above - >>> fig._hf_data + >>> fig.hf_data [ { 'max_n_samples': 1000, @@ -961,6 +964,7 @@ class FigureWidgetResampler( AbstractFigureAggregator, go.FigureWidget, metaclass=_FigureWidgetResamplerM ): """Data aggregation functionality wrapper for ``go.FigureWidgets``.""" + def __init__( self, figure: go.FigureWidget, @@ -985,20 +989,20 @@ def __init__( verbose, ) - self._prev_x_ranges = [] # Contains the previous x-range values - if verbose: - # used for logging purposes to save a history of layout changes - self._relayout_hist = [] + self._prev_layout = None # Contains the previous xaxis layout configuration + + # used for logging purposes to save a history of layout changes + self._relayout_hist = [] # A list of al xaxis string names e.g., "xaxis", "xaxis2", "xaxis3", .... self._xaxis_list = self._re_matches(re.compile("xaxis\d*"), self._layout.keys()) - # Assign the the methods to the update class - self._showspike_keys = [f"{xaxis}.showspikes" for xaxis in self._xaxis_list] - self.layout.on_change(self._update_spike_ranges, *self._showspike_keys) + # Assign the the update-methods to the corresponding classes + showspike_keys = [f"{xaxis}.showspikes" for xaxis in self._xaxis_list] + self.layout.on_change(self._update_spike_ranges, *showspike_keys) - self._x_relayout_keys = [f"{xaxis}.range" for xaxis in self._xaxis_list] - self.layout.on_change(self._update_x_ranges, *self._x_relayout_keys) + x_relayout_keys = [f"{xaxis}.range" for xaxis in self._xaxis_list] + self.layout.on_change(self._update_x_ranges, *x_relayout_keys) def _update_x_ranges(self, layout, *x_ranges): """Update the the go.Figure data based on changed x-ranges. @@ -1013,44 +1017,48 @@ def _update_x_ranges(self, layout, *x_ranges): items, indicating the current/new (if changed) left-right x-range, respectively. """ - if not len(self._prev_x_ranges): - self._prev_x_ranges = list(x_ranges) - relayout_dict = {} # variable in which we aim to reconstruct the relayout # serialize the layout in a new dict object layout = { xaxis_str: layout[xaxis_str].to_plotly_json() for xaxis_str in self._xaxis_list } + if self._prev_layout is None: + self._prev_layout = layout - for i, (xaxis_str, x_range) in enumerate(zip(self._xaxis_list, x_ranges)): + for xaxis_str, x_range in zip(self._xaxis_list, x_ranges): # We also check whether "range" is within the xaxis its layout otherwise # It is most-likely an autorange check - if "range" in layout[xaxis_str] and ( - self._prev_x_ranges[i][0] != x_range[0] - or self._prev_x_ranges[i][1] != x_range[1] + if ( + "range" in layout[xaxis_str] + and self._prev_layout[xaxis_str].get("range", []) != x_range ): # a change took place -> add to the relayout dict relayout_dict[f"{xaxis_str}.range[0]"] = x_range[0] relayout_dict[f"{xaxis_str}.range[1]"] = x_range[1] - # Update the previous x-ranges - self._prev_x_ranges = list(x_ranges) + # An update will take place for that trace + # -> save current xaxis range to _prev_layout + self._prev_layout[xaxis_str]['range'] = x_range if len(relayout_dict): # Construct the update data update_data = self.construct_update_data(relayout_dict) if self._print_verbose: - self._relayout_hist.append(dict(zip(self._x_relayout_keys, x_ranges))) + self._relayout_hist.append(dict(zip(self._xaxis_list, x_ranges))) self._relayout_hist.append(layout) - self._relayout_hist.append(["xaxis-range", len(update_data)]) + self._relayout_hist.append(["xaxis-range", len(update_data) - 1]) self._relayout_hist.append("-" * 30) with self.batch_update(): # First update the layout (first item of update_data) self.layout.update(update_data[0]) + for xaxis_str in self._xaxis_list: + if 'showspikes' in layout[xaxis_str]: + self.layout[xaxis_str].pop("showspikes") + # Then update the data for updated_trace in update_data[1:]: trace_idx = updated_trace.pop("index") @@ -1081,7 +1089,7 @@ def _update_spike_ranges(self, layout, *showspikes): layout[xaxis_str].get("autorange", False) # we only perform updates for traces which have 'range' property, # as we do need to reconstruct the update-data for these traces - and layout[xaxis_str].get("range", None) is not None + and self._prev_layout[xaxis_str].get("range", None) is not None # showspikes must also be present and "showspikes" in layout[xaxis_str] ): @@ -1089,12 +1097,14 @@ def _update_spike_ranges(self, layout, *showspikes): relayout_dict[f"{xaxis_str}.showspikes"] = showspike if len(relayout_dict): + # An update will take place, save current layout to _prev_layout + self._prev_layout = layout + # Construct the update data update_data = self.construct_update_data(relayout_dict) if self._print_verbose: - # self._relayout_hist.append(dict(zip(self._showspike_keys, showspikes))) self._relayout_hist.append(layout) - self._relayout_hist.append(["showspikes", len(update_data)]) + self._relayout_hist.append(["showspikes", len(update_data) - 1]) self._relayout_hist.append("-" * 30) with self.batch_update(): @@ -1112,15 +1122,17 @@ def _update_spike_ranges(self, layout, *showspikes): for updated_trace in update_data[1:]: trace_idx = updated_trace.pop("index") self.data[trace_idx].update(updated_trace) - elif self._print_verbose: self._relayout_hist.append(["showspikes", "initial call or showspikes"]) self._relayout_hist.append("-" * 40) + def show(self, *args, **kwargs): + super(go.FigureWidget, self).show(*args, **kwargs) class FigureResampler(AbstractFigureAggregator, go.Figure): """Data aggregation functionality for Figures.""" + def __init__( self, figure: go.Figure = go.Figure(), @@ -1244,4 +1256,4 @@ def stop_server(self, warn: bool = True): "Could not stop the server, either the \n" + "\t- 'show-dash' method was not called, or \n" + "\t- the dash-server wasn't started with 'show_dash'" - ) \ No newline at end of file + ) From 47611a258a1529939abe598bbcee9678a532c2bb Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Mon, 2 May 2022 19:21:15 +0200 Subject: [PATCH 06/19] :heavy_check_mark: first figurewidget tests --- tests/test_figurewidget_resampler.py | 504 +++++++++++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 tests/test_figurewidget_resampler.py diff --git a/tests/test_figurewidget_resampler.py b/tests/test_figurewidget_resampler.py new file mode 100644 index 00000000..d4d88e52 --- /dev/null +++ b/tests/test_figurewidget_resampler.py @@ -0,0 +1,504 @@ +"""Code which tests the FigureWidgetResampler functionalities""" + +__author__ = "Jonas Van Der Donckt, Jeroen Van Der Donckt, Emiel Deprost" + + +import pytest +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from plotly.subplots import make_subplots +from plotly_resampler import FigureWidgetResampler, EfficientLTTB, EveryNthPoint + + +def test_add_trace_kwarg_space(float_series, bool_series, cat_series): + # see: https://plotly.com/python/subplots/#custom-sized-subplot-with-subplot-titles + base_fig = make_subplots( + rows=2, + cols=2, + specs=[[{}, {}], [{"colspan": 2}, None]], + ) + + kwarg_space_list = [ + {}, + { + "default_downsampler": EfficientLTTB(interleave_gaps=True), + "resampled_trace_prefix_suffix": tuple(["[r]", "~~"]), + "verbose": True, + }, + ] + for kwarg_space in kwarg_space_list: + fig = FigureWidgetResampler(base_fig, **kwarg_space) + + fig.add_trace( + go.Scatter(x=float_series.index, y=float_series), + row=1, + col=1, + limit_to_view=False, + hf_hovertext="text", + ) + + fig.add_trace( + go.Scatter(text="text", name="bool_series"), + hf_x=bool_series.index, + hf_y=bool_series, + row=1, + col=2, + limit_to_view=True, + ) + + fig.add_trace( + go.Scattergl(text="text", name="cat_series"), + row=2, + col=1, + downsampler=EveryNthPoint(interleave_gaps=True), + hf_x=cat_series.index, + hf_y=cat_series, + limit_to_view=True, + ) + + +def test_add_trace_not_resampling(float_series): + # see: https://plotly.com/python/subplots/#custom-sized-subplot-with-subplot-titles + base_fig = make_subplots( + rows=2, + cols=2, + specs=[[{}, {}], [{"colspan": 2}, None]], + ) + + fig = FigureWidgetResampler(base_fig, default_n_shown_samples=1000) + + fig.add_trace( + go.Scatter( + x=float_series.index[:800], y=float_series[:800], name="float_series" + ), + row=1, + col=1, + hf_hovertext="text", + ) + + fig.add_trace( + go.Scatter(name="float_series"), + limit_to_view=False, + row=1, + col=1, + hf_x=float_series.index[-800:], + hf_y=float_series[-800:], + hf_hovertext="text", + ) + + +def test_add_scatter_trace_no_data(): + fig = FigureWidgetResampler(go.Figure(), default_n_shown_samples=1000) + + # no x and y data + fig.add_trace(go.Scatter()) + + +def test_add_scatter_trace_no_x(): + fig = FigureWidgetResampler(go.Figure(), default_n_shown_samples=1000) + + # no x data + fig.add_trace(go.Scatter(y=[2, 1, 4, 3], name="s1")) + fig.add_trace(go.Scatter(name="s2"), hf_y=[2, 1, 4, 3]) + + +def test_add_not_a_hf_trace(float_series): + # see: https://plotly.com/python/subplots/#custom-sized-subplot-with-subplot-titles + base_fig = make_subplots( + rows=2, + cols=2, + specs=[[{}, {}], [{"colspan": 2}, None]], + ) + + fig = FigureWidgetResampler(base_fig, default_n_shown_samples=1000, verbose=True) + + fig.add_trace( + go.Scatter( + x=float_series.index[:800], y=float_series[:800], name="float_series" + ), + row=1, + col=1, + hf_hovertext="text", + ) + + # add a not hf-trace + fig.add_trace( + go.Histogram( + x=float_series, + name="float_series", + ), + row=2, + col=1, + ) + + +def test_box_histogram(float_series): + base_fig = make_subplots( + rows=2, + cols=2, + specs=[[{}, {}], [{"colspan": 2}, None]], + ) + + fig = FigureWidgetResampler(base_fig, default_n_shown_samples=1000, verbose=True) + + fig.add_trace( + go.Scattergl(x=float_series.index, y=float_series, name="float_series"), + row=1, + col=1, + hf_hovertext="text", + ) + + fig.add_trace(go.Box(x=float_series.values, name="float_series"), row=1, col=2) + fig.add_trace( + go.Box(x=float_series.values**2, name="float_series**2"), row=1, col=2 + ) + + # add a not hf-trace + fig.add_trace( + go.Histogram( + x=float_series, + name="float_series", + ), + row=2, + col=1, + ) + + +def test_cat_box_histogram(float_series): + # Create a categorical series, with mostly a's, but a few sparse b's and c's + cats_list = np.array(list("aaaaaaaaaa" * 1000)) + cats_list[np.random.choice(len(cats_list), 100, replace=False)] = "b" + cats_list[np.random.choice(len(cats_list), 50, replace=False)] = "c" + cat_series = pd.Series(cats_list, dtype="category") + + base_fig = make_subplots( + rows=2, + cols=2, + specs=[[{}, {}], [{"colspan": 2}, None]], + ) + fig = FigureWidgetResampler(base_fig, default_n_shown_samples=1000, verbose=True) + + fig.add_trace( + go.Scattergl(name="cat_series", x=cat_series.index, y=cat_series), + row=1, + col=1, + hf_hovertext="text", + ) + + fig.add_trace(go.Box(x=float_series.values, name="float_box_pow"), row=1, col=2) + fig.add_trace( + go.Box(x=float_series.values**2, name="float_box_pow_2"), row=1, col=2 + ) + + # add a not hf-trace + fig.add_trace( + go.Histogram( + x=float_series, + name="float_hist", + ), + row=2, + col=1, + ) + + fig.update_layout(height=700) + + +def test_replace_figure(float_series): + # see: https://plotly.com/python/subplots/#custom-sized-subplot-with-subplot-titles + base_fig = make_subplots( + rows=2, + cols=2, + specs=[[{}, {}], [{"colspan": 2}, None]], + ) + + fr_fig = FigureWidgetResampler(base_fig, default_n_shown_samples=1000) + + go_fig = go.Figure() + go_fig.add_trace(go.Scattergl(x=float_series.index, y=float_series, name="fs")) + + fr_fig.replace(go_fig, convert_existing_traces=False) + # assert len(fr_fig.data) == 1 + assert len(fr_fig.data[0]["x"]) == len(float_series) + # the orig float series data must still be the orig shape (we passed a view so + # we must check this) + assert len(go_fig.data[0]["x"]) == len(float_series) + + fr_fig.replace(go_fig, convert_existing_traces=True) + # assert len(fr_fig.data) == 1 + assert len(fr_fig.data[0]["x"]) == 1000 + + # the orig float series data must still be the orig shape (we passed a view so + # we must check this) + assert len(go_fig.data[0]["x"]) == len(float_series) + + +def test_nan_removed_input(float_series): + # see: https://plotly.com/python/subplots/#custom-sized-subplot-with-subplot-titles + base_fig = make_subplots( + rows=2, + cols=2, + specs=[[{}, {}], [{"colspan": 2}, None]], + ) + + fig = FigureWidgetResampler( + base_fig, + default_n_shown_samples=1000, + resampled_trace_prefix_suffix=( + '[R]', + '[R]', + ), + ) + + float_series = float_series.copy() + float_series.iloc[np.random.choice(len(float_series), 100)] = np.nan + fig.add_trace( + go.Scatter(x=float_series.index, y=float_series, name="float_series"), + row=1, + col=1, + hf_hovertext="text", + ) + + # here we test whether we are able to deal with not-nan output + float_series.iloc[np.random.choice(len(float_series), 100)] = np.nan + fig.add_trace( + go.Scatter( + x=float_series.index, y=float_series + ), # we explicitly do not add a name + hf_hovertext="mean" + float_series.rolling(10).mean().round(2).astype("str"), + row=2, + col=1, + ) + + float_series.iloc[np.random.choice(len(float_series), 100)] = np.nan + fig.add_trace( + go.Scattergl( + x=float_series.index, + y=float_series, + text="mean" + float_series.rolling(10).mean().round(2).astype("str"), + ), + row=1, + col=2, + ) + + +def test_multiple_timezones(): + n = 5_050 + + dr = pd.date_range("2022-02-14", freq="s", periods=n, tz="UTC") + dr_v = np.random.randn(n) + + cs = [ + dr, + dr.tz_localize(None).tz_localize("Europe/Amsterdam"), + dr.tz_convert("Europe/Brussels"), + dr.tz_convert("Australia/Perth"), + dr.tz_convert("Australia/Canberra"), + ] + + fr_fig = FigureWidgetResampler( + make_subplots(rows=len(cs), cols=1, shared_xaxes=True), + default_n_shown_samples=500, + convert_existing_traces=False, + verbose=True, + ) + fr_fig.update_layout(height=min(300, 250 * len(cs))) + + for i, date_range in enumerate(cs, 1): + fr_fig.add_trace( + go.Scattergl(name=date_range.dtype.name.split(", ")[-1]), + hf_x=date_range, + hf_y=dr_v, + row=i, + col=1, + ) + + +def test_proper_copy_of_wrapped_fig(float_series): + plotly_fig = go.Figure() + plotly_fig.add_trace( + go.Scatter( + x=float_series.index, + y=float_series, + ) + ) + + plotly_resampler_fig = FigureWidgetResampler(plotly_fig, default_n_shown_samples=500) + + assert len(plotly_fig.data) == 1 + assert all(plotly_fig.data[0].x == float_series.index) + assert all(plotly_fig.data[0].y == float_series.values) + assert (len(plotly_fig.data[0].x) > 500) & (len(plotly_fig.data[0].y) > 500) + + assert len(plotly_resampler_fig.data) == 1 + assert len(plotly_resampler_fig.data[0].x) == 500 + assert len(plotly_resampler_fig.data[0].y) == 500 + + +def test_2d_input_y(): + # Create some dummy dataframe with a nan + df = pd.DataFrame( + index=np.arange(5_000), data={"a": np.arange(5_000), "b": np.arange(5_000)} + ) + df.iloc[42] = np.nan + + plotly_fig = go.Figure() + plotly_fig.add_trace( + go.Scatter( + x=df.index, + y=df[["a"]], # (100, 1) shape + ) + ) + + with pytest.raises(AssertionError) as e_info: + _ = FigureWidgetResampler( # does not alter plotly_fig + plotly_fig, + default_n_shown_samples=500, + ) + assert "1 dimensional" in e_info + + +def test_time_tz_slicing(): + n = 5050 + dr = pd.Series( + index=pd.date_range("2022-02-14", freq="s", periods=n, tz="UTC"), + data=np.random.randn(n), + ) + + cs = [ + dr, + dr.tz_localize(None), + dr.tz_localize(None).tz_localize("Europe/Amsterdam"), + dr.tz_convert("Europe/Brussels"), + dr.tz_convert("Australia/Perth"), + dr.tz_convert("Australia/Canberra"), + ] + + fig = FigureWidgetResampler(go.Figure()) + + for s in cs: + t_start, t_stop = sorted(s.iloc[np.random.randint(0, n, 2)].index) + out = fig._slice_time(s, t_start, t_stop) + assert (out.index[0] - t_start) <= pd.Timedelta(seconds=1) + assert (out.index[-1] - t_stop) <= pd.Timedelta(seconds=1) + + +def test_time_tz_slicing_different_timestamp(): + # construct a time indexed series with UTC timezone + n = 60 * 60 * 24 * 3 + dr = pd.Series( + index=pd.date_range("2022-02-14", freq="s", periods=n, tz="UTC"), + data=np.random.randn(n), + ) + + # create multiple other time zones + cs = [ + dr, + dr.tz_localize(None).tz_localize("Europe/Amsterdam"), + dr.tz_convert("Europe/Brussels"), + dr.tz_convert("Australia/Perth"), + dr.tz_convert("Australia/Canberra"), + ] + + fig = FigureWidgetResampler(go.Figure()) + for i, s in enumerate(cs): + t_start, t_stop = sorted(s.iloc[np.random.randint(0, n, 2)].index) + t_start = t_start.tz_convert(cs[(i + 1) % len(cs)].index.tz) + t_stop = t_stop.tz_convert(cs[(i + 1) % len(cs)].index.tz) + + # As each timezone in CS tz aware, using other timezones in `t_start` & `t_stop` + # will raise an AssertionError + with pytest.raises(AssertionError): + fig._slice_time(s, t_start, t_stop) + + +def test_different_tz_no_tz_series_slicing(): + n = 60 * 60 * 24 * 3 + dr = pd.Series( + index=pd.date_range("2022-02-14", freq="s", periods=n, tz="UTC"), + data=np.random.randn(n), + ) + + cs = [ + dr, + dr.tz_localize(None), + dr.tz_localize(None).tz_localize("Europe/Amsterdam"), + dr.tz_convert("Europe/Brussels"), + dr.tz_convert("Australia/Perth"), + dr.tz_convert("Australia/Canberra"), + ] + + fig = FigureWidgetResampler(go.Figure()) + + for i, s in enumerate(cs): + t_start, t_stop = sorted( + s.tz_localize(None).iloc[np.random.randint(n / 2, n, 2)].index + ) + # both timestamps now have the same tz + t_start = t_start.tz_localize(cs[(i + 1) % len(cs)].index.tz) + t_stop = t_stop.tz_localize(cs[(i + 1) % len(cs)].index.tz) + + # the s has no time-info -> assumption is made that s has the same time-zone + # the timestamps + out = fig._slice_time(s.tz_localize(None), t_start, t_stop) + assert (out.index[0].tz_localize(t_start.tz) - t_start) <= pd.Timedelta( + seconds=1 + ) + assert (out.index[-1].tz_localize(t_stop.tz) - t_stop) <= pd.Timedelta( + seconds=1 + ) + + +def test_multiple_tz_no_tz_series_slicing(): + n = 60 * 60 * 24 * 3 + dr = pd.Series( + index=pd.date_range("2022-02-14", freq="s", periods=n, tz="UTC"), + data=np.random.randn(n), + ) + + cs = [ + dr, + dr.tz_localize(None), + dr.tz_localize(None).tz_localize("Europe/Amsterdam"), + dr.tz_convert("Europe/Brussels"), + dr.tz_convert("Australia/Perth"), + dr.tz_convert("Australia/Canberra"), + ] + + fig = FigureWidgetResampler(go.Figure()) + + for i, s in enumerate(cs): + t_start, t_stop = sorted( + s.tz_localize(None).iloc[np.random.randint(n / 2, n, 2)].index + ) + # both timestamps now have the a different tz + t_start = t_start.tz_localize(cs[(i + 1) % len(cs)].index.tz) + t_stop = t_stop.tz_localize(cs[(i + 2) % len(cs)].index.tz) + + # Now the assumpton cannot be made that s ahd the same time-zone as the + # timestamps -> Assertionerror will be raised. + with pytest.raises(AssertionError): + fig._slice_time(s.tz_localize(None), t_start, t_stop) + + +def test_check_update_figure_dict(): + # mostly written to test the check_update_figure_dict with + # "updated_trace_indices" = None + fr = FigureWidgetResampler(go.Figure()) + n = 100_000 + x = np.arange(n) + y = np.sin(x) + fr.add_trace(go.Scattergl(name="test"), hf_x=x, hf_y=y) + fr._check_update_figure_dict(fr.to_dict()) + + +def test_hf_data_property(): + fr = FigureWidgetResampler(go.Figure(), default_n_shown_samples=2_000) + n = 100_000 + x = np.arange(n) + y = np.sin(x) + assert len(fr.hf_data) == 0 + fr.add_trace(go.Scattergl(name="test"), hf_x=x, hf_y=y) + assert len(fr.hf_data) == 1 + assert len(fr.hf_data[0]["x"]) == n + fr.hf_data[0] = -2 * y From 0f5ad42ac4172e4e2366a939178b098223968069 Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Mon, 2 May 2022 19:22:19 +0200 Subject: [PATCH 07/19] :sparkles: --- plotly_resampler/__init__.py | 3 ++- plotly_resampler/figure_resampler.py | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/plotly_resampler/__init__.py b/plotly_resampler/__init__.py index 0e964c97..b3f292fb 100644 --- a/plotly_resampler/__init__.py +++ b/plotly_resampler/__init__.py @@ -9,7 +9,7 @@ FuncAggregator, MinMaxOverlapAggregator, ) -from .figure_resampler import FigureResampler +from .figure_resampler import FigureResampler, FigureWidgetResampler __docformat__ = "numpy" __author__ = "Jonas Van Der Donckt, Jeroen Van Der Donckt, Emiel Deprost" @@ -18,6 +18,7 @@ __all__ = [ "__version__", "FigureResampler", + "FigureWidgetResampler", "EfficientLTTB", "MinMaxOverlapAggregator", "LTTB", diff --git a/plotly_resampler/figure_resampler.py b/plotly_resampler/figure_resampler.py index e05aba4f..29f94049 100644 --- a/plotly_resampler/figure_resampler.py +++ b/plotly_resampler/figure_resampler.py @@ -23,11 +23,8 @@ import numpy as np import pandas as pd import plotly.graph_objects as go - -try: - from jupyter_dash import JupyterDash -except: - from dash import Dash +from jupyter_dash import JupyterDash +from dash import Dash from plotly.basedatatypes import BaseTraceType, BaseFigure from trace_updater import TraceUpdater @@ -978,7 +975,9 @@ def __init__( show_mean_aggregation_size: bool = True, verbose: bool = False, ): - assert isinstance(figure, go.FigureWidget) + if not isinstance(figure, go.FigureWidget): + figure = go.FigureWidget(figure) + super().__init__( figure, convert_existing_traces, @@ -996,6 +995,9 @@ def __init__( # A list of al xaxis string names e.g., "xaxis", "xaxis2", "xaxis3", .... self._xaxis_list = self._re_matches(re.compile("xaxis\d*"), self._layout.keys()) + # edge case: an empty `go.Figure()` does not yet contain xaxis keys + if not len(self._xaxis_list): + self._xaxis_list = ['xaxis'] # Assign the the update-methods to the corresponding classes showspike_keys = [f"{xaxis}.showspikes" for xaxis in self._xaxis_list] @@ -1090,11 +1092,12 @@ def _update_spike_ranges(self, layout, *showspikes): # we only perform updates for traces which have 'range' property, # as we do need to reconstruct the update-data for these traces and self._prev_layout[xaxis_str].get("range", None) is not None - # showspikes must also be present - and "showspikes" in layout[xaxis_str] ): relayout_dict[f"{xaxis_str}.autorange"] = True relayout_dict[f"{xaxis_str}.showspikes"] = showspike + # autorange -> we pop the xaxis range + if 'range' in layout[xaxis_str]: + del layout[xaxis_str]['range'] if len(relayout_dict): # An update will take place, save current layout to _prev_layout From cf0b32b6ae6830e234d2eb4ce737dbf7086b6003 Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Mon, 2 May 2022 19:42:14 +0200 Subject: [PATCH 08/19] :lock: --- poetry.lock | 568 +++++++++++++++++++++++++++++++------------------ pyproject.toml | 11 +- 2 files changed, 367 insertions(+), 212 deletions(-) diff --git a/poetry.lock b/poetry.lock index 50468fb4..aa082c91 100644 --- a/poetry.lock +++ b/poetry.lock @@ -111,11 +111,11 @@ tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (> [[package]] name = "babel" -version = "2.9.1" +version = "2.10.1" description = "Internationalization utilities" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] pytz = ">=2015.7" @@ -130,11 +130,11 @@ python-versions = "*" [[package]] name = "beautifulsoup4" -version = "4.10.0" +version = "4.11.1" description = "Screen-scraping library" category = "dev" optional = false -python-versions = ">3.0.0" +python-versions = ">=3.6.0" [package.dependencies] soupsieve = ">1.2" @@ -145,44 +145,43 @@ lxml = ["lxml"] [[package]] name = "black" -version = "21.12b0" +version = "22.3.0" description = "The uncompromising code formatter." category = "dev" optional = false python-versions = ">=3.6.2" [package.dependencies] -click = ">=7.1.2" +click = ">=8.0.0" mypy-extensions = ">=0.4.3" -pathspec = ">=0.9.0,<1" +pathspec = ">=0.9.0" platformdirs = ">=2" -tomli = ">=0.2.6,<2.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} -typing-extensions = [ - {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}, - {version = "!=3.10.0.1", markers = "python_version >= \"3.10\""}, -] +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} [package.extras] colorama = ["colorama (>=0.4.3)"] d = ["aiohttp (>=3.7.4)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -python2 = ["typed-ast (>=1.4.3)"] uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "bleach" -version = "4.1.0" +version = "5.0.0" description = "An easy safelist-based HTML-sanitizing tool." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] -packaging = "*" six = ">=1.9.0" webencodings = "*" +[package.extras] +css = ["tinycss2 (>=1.1.0)"] +dev = ["pip-tools (==6.5.1)", "pytest (==7.1.1)", "flake8 (==4.0.1)", "tox (==3.24.5)", "sphinx (==4.3.2)", "twine (==4.0.0)", "wheel (==0.37.1)", "hashin (==0.17.0)", "black (==22.3.0)", "mypy (==0.942)"] + [[package]] name = "blinker" version = "1.4" @@ -510,7 +509,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [[package]] name = "importlib-resources" -version = "5.6.0" +version = "5.7.1" description = "Read resources from Python packages" category = "dev" optional = false @@ -533,7 +532,7 @@ python-versions = "*" [[package]] name = "ipykernel" -version = "6.12.1" +version = "6.13.0" description = "IPython Kernel for Jupyter" category = "main" optional = false @@ -594,6 +593,26 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "ipywidgets" +version = "7.7.0" +description = "IPython HTML widgets for Jupyter" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +ipykernel = ">=4.5.1" +ipython = {version = ">=4.0.0", markers = "python_version >= \"3.3\""} +ipython-genutils = ">=0.2.0,<0.3.0" +jupyterlab-widgets = {version = ">=1.0.0", markers = "python_version >= \"3.6\""} +nbformat = ">=4.2.0" +traitlets = ">=4.3.1" +widgetsnbextension = ">=3.6.0,<3.7.0" + +[package.extras] +test = ["pytest (>=3.6.0)", "pytest-cov", "mock"] + [[package]] name = "itsdangerous" version = "2.1.2" @@ -663,7 +682,7 @@ format_nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339- [[package]] name = "jupyter-client" -version = "7.2.1" +version = "7.2.2" description = "Jupyter protocol implementation and client libraries" category = "main" optional = false @@ -684,16 +703,19 @@ test = ["codecov", "coverage", "ipykernel (>=6.5)", "ipython", "mypy", "pre-comm [[package]] name = "jupyter-core" -version = "4.9.2" +version = "4.10.0" description = "Jupyter core package. A base package on which Jupyter projects rely." category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] pywin32 = {version = ">=1.0", markers = "sys_platform == \"win32\" and platform_python_implementation != \"PyPy\""} traitlets = "*" +[package.extras] +test = ["ipykernel", "pre-commit", "pytest", "pytest-cov", "pytest-timeout"] + [[package]] name = "jupyter-dash" version = "0.4.2" @@ -746,7 +768,7 @@ test = ["coverage", "pytest (>=6.0)", "pytest-cov", "pytest-mock", "pytest-timeo [[package]] name = "jupyterlab" -version = "3.3.2" +version = "3.3.4" description = "JupyterLab computational environment" category = "dev" optional = false @@ -763,23 +785,20 @@ packaging = "*" tornado = ">=6.1.0" [package.extras] -test = ["coverage", "pytest (>=6.0)", "pytest-cov", "pytest-console-scripts", "pytest-check-links (>=0.5)", "jupyterlab-server[test] (>=2.2,<3.0)", "requests", "requests-cache", "virtualenv", "check-manifest"] +test = ["check-manifest", "coverage", "jupyterlab-server", "pytest (>=6.0)", "pytest-cov", "pytest-console-scripts", "pytest-check-links (>=0.5)", "requests", "requests-cache", "virtualenv", "pre-commit"] ui-tests = ["build"] [[package]] name = "jupyterlab-pygments" -version = "0.1.2" +version = "0.2.2" description = "Pygments theme using JupyterLab CSS variables" category = "dev" optional = false -python-versions = "*" - -[package.dependencies] -pygments = ">=2.4.1,<3" +python-versions = ">=3.7" [[package]] name = "jupyterlab-server" -version = "2.12.0" +version = "2.13.0" description = "A set of server components for JupyterLab and JupyterLab like applications ." category = "dev" optional = false @@ -787,17 +806,25 @@ python-versions = ">=3.7" [package.dependencies] babel = "*" -entrypoints = ">=0.2.2" +importlib-metadata = {version = ">=3.6", markers = "python_version < \"3.10\""} jinja2 = ">=3.0.3" json5 = "*" jsonschema = ">=3.0.1" -jupyter-server = ">=1.8,<2.0" +jupyter-server = ">=1.8,<2" packaging = "*" requests = "*" [package.extras] openapi = ["openapi-core (>=0.14.2)", "ruamel.yaml"] -test = ["codecov", "ipykernel", "pytest (>=5.3.2)", "pytest-cov", "jupyter-server", "pytest-console-scripts", "strict-rfc3339", "wheel", "openapi-spec-validator (<0.5)", "openapi-core (>=0.14.2)", "ruamel.yaml"] +test = ["openapi-core (>=0.14.2)", "ruamel.yaml", "codecov", "ipykernel", "jupyter-server", "openapi-spec-validator (<0.5)", "pytest-console-scripts", "pytest-cov", "pytest (>=5.3.2)", "strict-rfc3339", "wheel"] + +[[package]] +name = "jupyterlab-widgets" +version = "1.1.0" +description = "A JupyterLab extension." +category = "dev" +optional = false +python-versions = ">=3.6" [[package]] name = "kaitaistruct" @@ -807,6 +834,20 @@ category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +[[package]] +name = "line-profiler" +version = "3.5.1" +description = "Line-by-line profiler." +category = "dev" +optional = false +python-versions = "*" + +[package.extras] +all = ["cython", "scikit-build", "cmake", "ninja", "pytest (>=4.6.11)", "pytest-cov (>=2.10.1)", "coverage[toml] (>=5.3)", "ubelt (>=1.0.1)", "IPython (>=0.13,<7.17.0)", "IPython (>=0.13)"] +build = ["cython", "scikit-build", "cmake", "ninja"] +ipython = ["IPython (>=0.13,<7.17.0)", "IPython (>=0.13)"] +tests = ["pytest (>=4.6.11)", "pytest-cov (>=2.10.1)", "coverage[toml] (>=5.3)", "ubelt (>=1.0.1)", "IPython (>=0.13,<7.17.0)", "IPython (>=0.13)"] + [[package]] name = "lttbc" version = "0.2.0" @@ -837,6 +878,17 @@ python-versions = ">=3.5" [package.dependencies] traitlets = "*" +[[package]] +name = "memory-profiler" +version = "0.60.0" +description = "A module for monitoring memory usage of a python program" +category = "dev" +optional = false +python-versions = ">=3.4" + +[package.dependencies] +psutil = "*" + [[package]] name = "mistune" version = "0.8.4" @@ -871,7 +923,7 @@ test = ["pytest", "pytest-tornasync", "pytest-console-scripts"] [[package]] name = "nbclient" -version = "0.5.13" +version = "0.6.0" description = "A client library for executing notebooks. Formerly nbconvert's ExecutePreprocessor." category = "dev" optional = false @@ -884,12 +936,12 @@ nest-asyncio = "*" traitlets = ">=5.0.0" [package.extras] -sphinx = ["Sphinx (>=1.7)", "sphinx-book-theme", "mock", "moto", "myst-parser"] -test = ["ipython (<8.0.0)", "ipykernel", "ipywidgets (<8.0.0)", "pytest (>=4.1)", "pytest-asyncio", "pytest-cov (>=2.6.1)", "check-manifest", "flake8", "mypy", "xmltodict", "black", "pip (>=18.1)", "wheel (>=0.31.0)", "setuptools (>=38.6.0)", "twine (>=1.11.0)"] +sphinx = ["mock", "moto", "myst-parser", "Sphinx (>=1.7)", "sphinx-book-theme"] +test = ["black", "check-manifest", "flake8", "ipykernel", "ipython (<8.0.0)", "ipywidgets (<8.0.0)", "mypy", "pip (>=18.1)", "pre-commit", "pytest (>=4.1)", "pytest-asyncio", "pytest-cov (>=2.6.1)", "setuptools (>=60.0)", "testpath", "twine (>=1.11.0)", "xmltodict"] [[package]] name = "nbconvert" -version = "6.4.5" +version = "6.5.0" description = "Converting Jupyter Notebooks" category = "dev" optional = false @@ -900,23 +952,24 @@ beautifulsoup4 = "*" bleach = "*" defusedxml = "*" entrypoints = ">=0.2.2" -jinja2 = ">=2.4" -jupyter-core = "*" +jinja2 = ">=3.0" +jupyter-core = ">=4.7" jupyterlab-pygments = "*" MarkupSafe = ">=2.0" mistune = ">=0.8.1,<2" -nbclient = ">=0.5.0,<0.6.0" -nbformat = ">=4.4" +nbclient = ">=0.5.0" +nbformat = ">=5.1" +packaging = "*" pandocfilters = ">=1.4.1" pygments = ">=2.4.1" -testpath = "*" +tinycss2 = "*" traitlets = ">=5.0" [package.extras] -all = ["pytest", "pytest-cov", "pytest-dependency", "ipykernel", "ipywidgets (>=7)", "pyppeteer (>=1,<1.1)", "tornado (>=4.0)", "sphinx (>=1.5.1)", "sphinx-rtd-theme", "nbsphinx (>=0.2.12)", "ipython"] +all = ["pytest", "pytest-cov", "pytest-dependency", "ipykernel", "ipywidgets (>=7)", "pre-commit", "pyppeteer (>=1,<1.1)", "tornado (>=6.1)", "sphinx (>=1.5.1)", "sphinx-rtd-theme", "nbsphinx (>=0.2.12)", "ipython"] docs = ["sphinx (>=1.5.1)", "sphinx-rtd-theme", "nbsphinx (>=0.2.12)", "ipython"] -serve = ["tornado (>=4.0)"] -test = ["pytest", "pytest-cov", "pytest-dependency", "ipykernel", "ipywidgets (>=7)", "pyppeteer (>=1,<1.1)"] +serve = ["tornado (>=6.1)"] +test = ["pytest", "pytest-cov", "pytest-dependency", "ipykernel", "ipywidgets (>=7)", "pre-commit", "pyppeteer (>=1,<1.1)"] webpdf = ["pyppeteer (>=1,<1.1)"] [[package]] @@ -946,11 +999,11 @@ python-versions = ">=3.5" [[package]] name = "notebook" -version = "6.4.10" +version = "6.4.11" description = "A web-based notebook environment for interactive computing" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] argon2-cffi = "*" @@ -972,7 +1025,7 @@ traitlets = ">=4.2.1" [package.extras] docs = ["sphinx", "nbsphinx", "sphinxcontrib-github-alt", "sphinx-rtd-theme", "myst-parser"] json-logging = ["json-logging"] -test = ["pytest", "coverage", "requests", "nbval", "selenium", "pytest-cov", "requests-unixsocket"] +test = ["pytest", "coverage", "requests", "testpath", "nbval", "selenium", "pytest-cov", "requests-unixsocket"] [[package]] name = "notebook-shim" @@ -990,7 +1043,7 @@ test = ["pytest", "pytest-tornasync", "pytest-console-scripts"] [[package]] name = "numpy" -version = "1.21.5" +version = "1.21.6" description = "NumPy is the fundamental package for array computing with Python." category = "main" optional = false @@ -998,7 +1051,7 @@ python-versions = ">=3.7,<3.11" [[package]] name = "orjson" -version = "3.6.7" +version = "3.6.8" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" category = "main" optional = false @@ -1096,19 +1149,19 @@ python-versions = "*" [[package]] name = "platformdirs" -version = "2.5.1" +version = "2.5.2" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.7" [package.extras] -docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] -test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] +test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] [[package]] name = "plotly" -version = "5.6.0" +version = "5.7.0" description = "An open-source, interactive data visualization library for Python" category = "main" optional = false @@ -1135,7 +1188,7 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "prometheus-client" -version = "0.13.1" +version = "0.14.1" description = "Python client for the Prometheus monitoring system." category = "dev" optional = false @@ -1258,11 +1311,11 @@ all = ["pandas (>=1.0.3,<2.0.0)"] [[package]] name = "pygments" -version = "2.11.2" +version = "2.12.0" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [[package]] name = "pyopenssl" @@ -1281,14 +1334,14 @@ test = ["flaky", "pretend", "pytest (>=3.0.1)"] [[package]] name = "pyparsing" -version = "3.0.7" -description = "Python parsing module" +version = "3.0.8" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.6.8" [package.extras] -diagrams = ["jinja2", "railroad-diagrams"] +diagrams = ["railroad-diagrams", "jinja2"] [[package]] name = "pyrsistent" @@ -1577,7 +1630,7 @@ python-versions = "*" [[package]] name = "soupsieve" -version = "2.3.1" +version = "2.3.2.post1" description = "A modern CSS selector implementation for Beautiful Soup." category = "dev" optional = false @@ -1617,18 +1670,18 @@ test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"] [[package]] name = "sphinx-autodoc-typehints" -version = "1.17.0" +version = "1.18.1" description = "Type hints (PEP 484) support for the Sphinx autodoc extension" category = "dev" optional = false python-versions = ">=3.7" [package.dependencies] -Sphinx = ">=4" +Sphinx = ">=4.5" [package.extras] -testing = ["covdefaults (>=2)", "coverage (>=6)", "diff-cover (>=6.4)", "nptyping (>=1)", "pytest (>=6)", "pytest-cov (>=3)", "sphobjinv (>=2)", "typing-extensions (>=3.5)"] -type_comments = ["typed-ast (>=1.4.0)"] +testing = ["covdefaults (>=2.2)", "coverage (>=6.3)", "diff-cover (>=6.4)", "nptyping (>=2)", "pytest (>=7.1)", "pytest-cov (>=3)", "sphobjinv (>=2)", "typing-extensions (>=4.1)"] +type_comments = ["typed-ast (>=1.5.2)"] [[package]] name = "sphinxcontrib-applehelp" @@ -1743,15 +1796,19 @@ tornado = ">=4" test = ["pytest"] [[package]] -name = "testpath" -version = "0.6.0" -description = "Test utilities for code working with files and commands" +name = "tinycss2" +version = "1.1.1" +description = "A tiny CSS parser" category = "dev" optional = false -python-versions = ">= 3.5" +python-versions = ">=3.6" + +[package.dependencies] +webencodings = ">=0.4" [package.extras] -test = ["pytest"] +doc = ["sphinx", "sphinx-rtd-theme"] +test = ["pytest", "pytest-cov", "pytest-flake8", "pytest-isort", "coverage"] [[package]] name = "toml" @@ -1828,7 +1885,7 @@ wsproto = ">=0.14" [[package]] name = "typed-ast" -version = "1.5.2" +version = "1.5.3" description = "a fork of Python 2 and 3 ast modules with type comment support" category = "dev" optional = false @@ -1836,11 +1893,11 @@ python-versions = ">=3.6" [[package]] name = "typing-extensions" -version = "4.1.1" -description = "Backported and Experimental Type Hints for Python 3.6+" +version = "4.2.0" +description = "Backported and Experimental Type Hints for Python 3.7+" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "urllib3" @@ -1913,6 +1970,17 @@ python-versions = ">=3.7" [package.extras] watchdog = ["watchdog"] +[[package]] +name = "widgetsnbextension" +version = "3.6.0" +description = "IPython HTML widgets for Jupyter" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +notebook = ">=4.4.1" + [[package]] name = "wsproto" version = "1.1.0" @@ -1953,7 +2021,7 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "1.1" python-versions = "^3.7.1,<3.11" -content-hash = "b61ae9f9b0de7d528471ed38154a1ff5ac3b4a23acce51e30f629b8e01789480" +content-hash = "f5abe15b64d1eab7fac29c5e7599833783536c8ddb557759f126602ca69fa5b5" [metadata.files] alabaster = [ @@ -2012,24 +2080,45 @@ attrs = [ {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, ] babel = [ - {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, - {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, + {file = "Babel-2.10.1-py3-none-any.whl", hash = "sha256:3f349e85ad3154559ac4930c3918247d319f21910d5ce4b25d439ed8693b98d2"}, + {file = "Babel-2.10.1.tar.gz", hash = "sha256:98aeaca086133efb3e1e2aad0396987490c8425929ddbcfe0550184fdc54cd13"}, ] backcall = [ {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, ] beautifulsoup4 = [ - {file = "beautifulsoup4-4.10.0-py3-none-any.whl", hash = "sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf"}, - {file = "beautifulsoup4-4.10.0.tar.gz", hash = "sha256:c23ad23c521d818955a4151a67d81580319d4bf548d3d49f4223ae041ff98891"}, + {file = "beautifulsoup4-4.11.1-py3-none-any.whl", hash = "sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30"}, + {file = "beautifulsoup4-4.11.1.tar.gz", hash = "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"}, ] black = [ - {file = "black-21.12b0-py3-none-any.whl", hash = "sha256:a615e69ae185e08fdd73e4715e260e2479c861b5740057fde6e8b4e3b7dd589f"}, - {file = "black-21.12b0.tar.gz", hash = "sha256:77b80f693a569e2e527958459634f18df9b0ba2625ba4e0c2d5da5be42e6f2b3"}, + {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, + {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, + {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, + {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, + {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, + {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, + {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, + {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, + {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, + {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, + {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, + {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, + {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, + {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, + {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, + {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, + {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, + {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, + {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, + {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, + {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, + {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, + {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, ] bleach = [ - {file = "bleach-4.1.0-py2.py3-none-any.whl", hash = "sha256:4d2651ab93271d1129ac9cbc679f524565cc8a1b791909c4a51eac4446a15994"}, - {file = "bleach-4.1.0.tar.gz", hash = "sha256:0900d8b37eba61a802ee40ac0061f8c2b5dee29c1927dd1d233e075ebf5a71da"}, + {file = "bleach-5.0.0-py3-none-any.whl", hash = "sha256:08a1fe86d253b5c88c92cc3d810fd8048a16d15762e1e5b74d502256e5926aa1"}, + {file = "bleach-5.0.0.tar.gz", hash = "sha256:c6d6cc054bdc9c83b48b8083e236e5f00f238428666d2ce2e083eaa5fd568565"}, ] blinker = [ {file = "blinker-1.4.tar.gz", hash = "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"}, @@ -2314,16 +2403,16 @@ importlib-metadata = [ {file = "importlib_metadata-4.11.3.tar.gz", hash = "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"}, ] importlib-resources = [ - {file = "importlib_resources-5.6.0-py3-none-any.whl", hash = "sha256:a9dd72f6cc106aeb50f6e66b86b69b454766dd6e39b69ac68450253058706bcc"}, - {file = "importlib_resources-5.6.0.tar.gz", hash = "sha256:1b93238cbf23b4cde34240dd8321d99e9bf2eb4bc91c0c99b2886283e7baad85"}, + {file = "importlib_resources-5.7.1-py3-none-any.whl", hash = "sha256:e447dc01619b1e951286f3929be820029d48c75eb25d265c28b92a16548212b8"}, + {file = "importlib_resources-5.7.1.tar.gz", hash = "sha256:b6062987dfc51f0fcb809187cffbd60f35df7acb4589091f154214af6d0d49d3"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] ipykernel = [ - {file = "ipykernel-6.12.1-py3-none-any.whl", hash = "sha256:d840e3bf1c4b23bf6939f78dcdae639c9f6962e41d17e1c084a18c3c7f972d3a"}, - {file = "ipykernel-6.12.1.tar.gz", hash = "sha256:0868f5561729ade444011f8ca7d3502dc9f27f7f44e20f1d5fee7e1f2b7183a1"}, + {file = "ipykernel-6.13.0-py3-none-any.whl", hash = "sha256:2b0987af43c0d4b62cecb13c592755f599f96f29aafe36c01731aaa96df30d39"}, + {file = "ipykernel-6.13.0.tar.gz", hash = "sha256:0e28273e290858393e86e152b104e5506a79c13d25b951ac6eca220051b4be60"}, ] ipython = [ {file = "ipython-7.32.0-py3-none-any.whl", hash = "sha256:86df2cf291c6c70b5be6a7b608650420e89180c8ec74f376a34e2dc15c3400e7"}, @@ -2333,6 +2422,10 @@ ipython-genutils = [ {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"}, {file = "ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"}, ] +ipywidgets = [ + {file = "ipywidgets-7.7.0-py2.py3-none-any.whl", hash = "sha256:e58ff58bc94d481e91ecb6e13a5cb96a87b6b8ade135e055603d0ca24593df38"}, + {file = "ipywidgets-7.7.0.tar.gz", hash = "sha256:ab4a5596855a88b83761921c768707d65e5847068139bc1729ddfe834703542a"}, +] itsdangerous = [ {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, @@ -2354,12 +2447,12 @@ jsonschema = [ {file = "jsonschema-4.4.0.tar.gz", hash = "sha256:636694eb41b3535ed608fe04129f26542b59ed99808b4f688aa32dcf55317a83"}, ] jupyter-client = [ - {file = "jupyter_client-7.2.1-py3-none-any.whl", hash = "sha256:d10e31ac4b8364d1cb30ebcee9e5cc7b7eb5d23b76912be9ef3d4c75167fbc68"}, - {file = "jupyter_client-7.2.1.tar.gz", hash = "sha256:aa177279e93205d0681ec0e2e210da01b22c5a1464a56abd455adcac64f0de91"}, + {file = "jupyter_client-7.2.2-py3-none-any.whl", hash = "sha256:44045448eadc12493d819d965eb1dc9d10d1927698adbb9b14eb9a3a4a45ba53"}, + {file = "jupyter_client-7.2.2.tar.gz", hash = "sha256:8fdbad344a8baa6a413d86d25bbf87ce21cb2b4aa5a8e0413863b9754eb8eb8a"}, ] jupyter-core = [ - {file = "jupyter_core-4.9.2-py3-none-any.whl", hash = "sha256:f875e4d27e202590311d468fa55f90c575f201490bd0c18acabe4e318db4a46d"}, - {file = "jupyter_core-4.9.2.tar.gz", hash = "sha256:d69baeb9ffb128b8cd2657fcf2703f89c769d1673c851812119e3a2a0e93ad9a"}, + {file = "jupyter_core-4.10.0-py3-none-any.whl", hash = "sha256:e7f5212177af7ab34179690140f188aa9bf3d322d8155ed972cbded19f55b6f3"}, + {file = "jupyter_core-4.10.0.tar.gz", hash = "sha256:a6de44b16b7b31d7271130c71a6792c4040f077011961138afed5e5e73181aec"}, ] jupyter-dash = [ {file = "jupyter-dash-0.4.2.tar.gz", hash = "sha256:d546c7c25a2867c14c95a48af0ad572803b26915a5ce6052158c9dede4dbf48c"}, @@ -2370,20 +2463,71 @@ jupyter-server = [ {file = "jupyter_server-1.16.0.tar.gz", hash = "sha256:c756f87ad64b84e2aa522ef482445e1a93f7fe4a5fc78358f4636e53c9a0463a"}, ] jupyterlab = [ - {file = "jupyterlab-3.3.2-py3-none-any.whl", hash = "sha256:32c9e3fae93d02f7a071f5e69a7a5450fa4bf087dd3d5aca58c7dd2adf2565d3"}, - {file = "jupyterlab-3.3.2.tar.gz", hash = "sha256:3c716bf5592cb28c5c55c615c6e5bd3efc71898f6957d13719b56478bbbb587a"}, + {file = "jupyterlab-3.3.4-py3-none-any.whl", hash = "sha256:87121636963027a0477e50ea8f366acf1ab06bb05d7e581cd2ec8c00f6e741a5"}, + {file = "jupyterlab-3.3.4.tar.gz", hash = "sha256:e04355848b3d91ac4d95c2e3846a0429b33e9c2edc79668fb4fc4d212f1e5107"}, ] jupyterlab-pygments = [ - {file = "jupyterlab_pygments-0.1.2-py2.py3-none-any.whl", hash = "sha256:abfb880fd1561987efaefcb2d2ac75145d2a5d0139b1876d5be806e32f630008"}, - {file = "jupyterlab_pygments-0.1.2.tar.gz", hash = "sha256:cfcda0873626150932f438eccf0f8bf22bfa92345b814890ab360d666b254146"}, + {file = "jupyterlab_pygments-0.2.2-py2.py3-none-any.whl", hash = "sha256:2405800db07c9f770863bcf8049a529c3dd4d3e28536638bd7c1c01d2748309f"}, + {file = "jupyterlab_pygments-0.2.2.tar.gz", hash = "sha256:7405d7fde60819d905a9fa8ce89e4cd830e318cdad22a0030f7a901da705585d"}, ] jupyterlab-server = [ - {file = "jupyterlab_server-2.12.0-py3-none-any.whl", hash = "sha256:db5d234955c5c2684f77a064345712f071acf7df31f0d8c31b420b33b09d6472"}, - {file = "jupyterlab_server-2.12.0.tar.gz", hash = "sha256:00e0f4b4c399f55938323ea10cf92d915288fe12753e35d1069f6ca08b72abbf"}, + {file = "jupyterlab_server-2.13.0-py3-none-any.whl", hash = "sha256:fc9e86d4e7c4b139de59b0a96b53071e670bee1ed106a3389daecd68f1221aeb"}, + {file = "jupyterlab_server-2.13.0.tar.gz", hash = "sha256:2040298a133458aa22f287a877d6bb91ff973f6298d562264f9f7b75e92a5ace"}, +] +jupyterlab-widgets = [ + {file = "jupyterlab_widgets-1.1.0-py3-none-any.whl", hash = "sha256:c2a9bd3789f120f64d73268c066ed3b000c56bc1dda217be5cdc43e7b4ebad3f"}, + {file = "jupyterlab_widgets-1.1.0.tar.gz", hash = "sha256:d5f41bc1713795385f718d44dcba47e1e1473c6289f28a95aa6b2c0782ee372a"}, ] kaitaistruct = [ {file = "kaitaistruct-0.9.tar.gz", hash = "sha256:3d5845817ec8a4d5504379cc11bd570b038850ee49c4580bc0998c8fb1d327ad"}, ] +line-profiler = [ + {file = "line_profiler-3.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:409e32944176d4004df4308cc37674c1e48ea7444918c129edf5da68ded305c6"}, + {file = "line_profiler-3.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d675732d221b5a4bfe48f57bd0ed2f759ad919e650890f4f5f1cf6536c1bc23"}, + {file = "line_profiler-3.5.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf3c88730d8a39a03c536d729f50d78d0947bf836c5809993781c8d730a7a4a2"}, + {file = "line_profiler-3.5.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:916ba4f353fe0c6edf44394d02de8ea4e6bd5225e3c8c876a6879e8c61fec36a"}, + {file = "line_profiler-3.5.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4086531248ca399fecace5a2fb1c6e0723e07d72406b123f1f9ff91d0519ac7c"}, + {file = "line_profiler-3.5.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7ee3e8df7ee4fa6ce12010adf4a5938862367b7d903614568abae307ffa46062"}, + {file = "line_profiler-3.5.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8971a6ccd7f0ffda45f30ca39b55877c455fc020308336093d6e468352436196"}, + {file = "line_profiler-3.5.1-cp310-cp310-win32.whl", hash = "sha256:ff31ae34e3db3c161321d714106e9d3b9755c231ef1b716539fefc49b2855d21"}, + {file = "line_profiler-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee44195421ccb95f8f039d27d8ec797f3ad25e816c08365302c8b03963a798e6"}, + {file = "line_profiler-3.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:691c66477ed832141e76359d5b25db97d04cf9620fbce6a84de1989eb0d3a2fe"}, + {file = "line_profiler-3.5.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbe8a7e9f38721020ce3fcb73567dc862735c9a138458477840b4fb03440153"}, + {file = "line_profiler-3.5.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dddd5f2239d716ed471d9b0ae5ecaae612c051e53acc592331dc1e467c630366"}, + {file = "line_profiler-3.5.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b69c751b6619d36f3870512840e0190b9d19f76fb09183ce3274c69544ab959"}, + {file = "line_profiler-3.5.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:b932246545b6108a3bf615d2b0e5a2c905b6f4a127d27608d996a0c6ea0f2b7a"}, + {file = "line_profiler-3.5.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:801fd7f357e8fc6910441e0b26fad311a6e81b2d34b90b64ba4be3bc366ff193"}, + {file = "line_profiler-3.5.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:9a26590d701aebc8ce80930e623596b16cf03db44ae6845b956559b51b1f4d8f"}, + {file = "line_profiler-3.5.1-cp36-cp36m-win32.whl", hash = "sha256:2d5de461e7ff4662b8c32a8328974e6e0ad433e1dc2e596c7105d8a8ffcc6dc4"}, + {file = "line_profiler-3.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:25930cb6d4a72f2f2e238bd80d0a875ec79ee98910be0aa969c7ca45ec68efb1"}, + {file = "line_profiler-3.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:71b939326d3d385372c5891900afc06e65eacdc108b28da006f59f4fee937c61"}, + {file = "line_profiler-3.5.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ef1acc35f8ffa8b4027963c1f596bfd7b2b279eecb8cbb0c662befeb09fc443"}, + {file = "line_profiler-3.5.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b8d03ab7f09af40140ec9e616d1b6dfa9b90495bd2d65a0a47052a147274bc"}, + {file = "line_profiler-3.5.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b54ff0f75736631f2b956c35ce7436519b8b8a40f99909106eb409140ed51190"}, + {file = "line_profiler-3.5.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:532604be45bcf1f581d9784350d7b5775b15565bf1355905edd4892aa601b40d"}, + {file = "line_profiler-3.5.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3a5b6287752468c4548fae267dbb0f6ccb5db5d16c8828886e1ef29ffdfa9e2d"}, + {file = "line_profiler-3.5.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4b680c3746a585df81e8200d28a94bfef9c7ae0748752012c90ddb4e5ca51440"}, + {file = "line_profiler-3.5.1-cp37-cp37m-win32.whl", hash = "sha256:f7f7e3de6dab51209ee1e2efe48e4c832d23d166a349dd37dedb6b0545931171"}, + {file = "line_profiler-3.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:93407999338a446b682cd2203b09d3c461e96ba5ab7b98900b3e43c51ca50986"}, + {file = "line_profiler-3.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eaae9d4583160a963ed37850dabb564311fbe90a2a93add52230ece25bd861e4"}, + {file = "line_profiler-3.5.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c906fa9f8bcbdd3d2e5c8bd3245924c6b0a1563ca2134560d8ba3509723b8ed4"}, + {file = "line_profiler-3.5.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c00208161aa03f220df57c5a7eaa734332221d64b91cea5c45d4405a1d1f056"}, + {file = "line_profiler-3.5.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef1e3b30a26e7bc24b4b4f5d0107190a200bee19c0ee0074a06a3389ab578889"}, + {file = "line_profiler-3.5.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b5357eb328b425a4b5ba20e2b70f94d8c6a43b38b968dffe91fc0600a35b0a03"}, + {file = "line_profiler-3.5.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:82b02a7b18b307258bed445dd13ab351e53737cb7fe212a5670d98f4271b4b69"}, + {file = "line_profiler-3.5.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3917f4d5a01ea297b0474898f51c5dd1a7df4806ffc019da28d04089fc4e0896"}, + {file = "line_profiler-3.5.1-cp38-cp38-win32.whl", hash = "sha256:13df519e1cdf63e16325ec6cab1f441c8b588dc6148dfdd92e99f44521dc74e8"}, + {file = "line_profiler-3.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:8d2280cf96644137b8033f74e712f8bce80c509f08c8a64546b795905b035066"}, + {file = "line_profiler-3.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:861ccf4981867ee44e381bcfffff98c1572ba055903d52d7eb4b345c66e992e8"}, + {file = "line_profiler-3.5.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9102ddec1008faf4861f72ed4ec1f7a338ffe7230ead5ea7545388f57cbc39a"}, + {file = "line_profiler-3.5.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d38e2e878ba47fc1f9a0e4194c0d4fa034c3c9eb9fbc954c0ccb4046672ed326"}, + {file = "line_profiler-3.5.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9efc10ac7ff16f8fe9f1ce6a0a783db2e3f617d5916f8d58a41f6afb13841694"}, + {file = "line_profiler-3.5.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d7b3cb1718bce1d35d40b3ab0bbbd528c67d930f9308622aabeaf2ed26f163ba"}, + {file = "line_profiler-3.5.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e2b6cbc9ad958c3421df6b57e318bfaa78b9e1697528729e0a0b00d25b4a7c7a"}, + {file = "line_profiler-3.5.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:20233371c4abf160358dbbf0702228d9dd72c66682eb284651db3a76d6d1e9f0"}, + {file = "line_profiler-3.5.1-cp39-cp39-win32.whl", hash = "sha256:2ea6ce644513bb53047c3081702371869a54ffafb2cb523c6c6b6589da623764"}, + {file = "line_profiler-3.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:0c717a9c08255da9f79595330f20502a32806ba96823716b8a42b26ee7b0f183"}, +] lttbc = [ {file = "lttbc-0.2.0-cp35-cp35m-win_amd64.whl", hash = "sha256:c374a32d9d3404e77cca95ef07dec79a141390f608dc4782b7276bd6e701f793"}, {file = "lttbc-0.2.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:df2c42991650433157404eec05599905bb4a3af8a2ada1f3e2dbac42236bfa18"}, @@ -2439,6 +2583,9 @@ matplotlib-inline = [ {file = "matplotlib-inline-0.1.3.tar.gz", hash = "sha256:a04bfba22e0d1395479f866853ec1ee28eea1485c1d69a6faf00dc3e24ff34ee"}, {file = "matplotlib_inline-0.1.3-py3-none-any.whl", hash = "sha256:aed605ba3b72462d64d475a21a9296f400a19c4f74a31b59103d2a99ffd5aa5c"}, ] +memory-profiler = [ + {file = "memory_profiler-0.60.0.tar.gz", hash = "sha256:6a12869511d6cebcb29b71ba26985675a58e16e06b3c523b49f67c5497a33d1c"}, +] mistune = [ {file = "mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4"}, {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, @@ -2452,12 +2599,12 @@ nbclassic = [ {file = "nbclassic-0.3.7.tar.gz", hash = "sha256:36dbaa88ffaf5dc05d149deb97504b86ba648f4a80a60b8a58ac94acab2daeb5"}, ] nbclient = [ - {file = "nbclient-0.5.13-py3-none-any.whl", hash = "sha256:47ac905af59379913c1f8f541098d2550153cf8dc58553cbe18c702b181518b0"}, - {file = "nbclient-0.5.13.tar.gz", hash = "sha256:40c52c9b5e3c31faecaee69f202b3f53e38d7c1c563de0fadde9d7eda0fdafe8"}, + {file = "nbclient-0.6.0-py3-none-any.whl", hash = "sha256:2eed35fc954716cdf0a01ea8cbdd9f9316761479008570059e2f5de29e139423"}, + {file = "nbclient-0.6.0.tar.gz", hash = "sha256:3f89a403c6badf24d2855a455b69a80985b3b27e04111243fdb6a88a28d27031"}, ] nbconvert = [ - {file = "nbconvert-6.4.5-py3-none-any.whl", hash = "sha256:e01d219f55cc79f9701c834d605e8aa3acf35725345d3942e3983937f368ce14"}, - {file = "nbconvert-6.4.5.tar.gz", hash = "sha256:21163a8e2073c07109ca8f398836e45efdba2aacea68d6f75a8a545fef070d4e"}, + {file = "nbconvert-6.5.0-py3-none-any.whl", hash = "sha256:c56dd0b8978a1811a5654f74c727ff16ca87dd5a43abd435a1c49b840fcd8360"}, + {file = "nbconvert-6.5.0.tar.gz", hash = "sha256:223e46e27abe8596b8aed54301fadbba433b7ffea8196a68fd7b1ff509eee99d"}, ] nbformat = [ {file = "nbformat-5.3.0-py3-none-any.whl", hash = "sha256:38856d97de49e8292e2d5d8f595e9d26f02abfd87e075d450af4511870b40538"}, @@ -2468,78 +2615,79 @@ nest-asyncio = [ {file = "nest_asyncio-1.5.5.tar.gz", hash = "sha256:e442291cd942698be619823a17a86a5759eabe1f8613084790de189fe9e16d65"}, ] notebook = [ - {file = "notebook-6.4.10-py3-none-any.whl", hash = "sha256:49cead814bff0945fcb2ee07579259418672ac175d3dc3d8102a4b0a656ed4df"}, - {file = "notebook-6.4.10.tar.gz", hash = "sha256:2408a76bc6289283a8eecfca67e298ec83c67db51a4c2e1b713dd180bb39e90e"}, + {file = "notebook-6.4.11-py3-none-any.whl", hash = "sha256:b4a6baf2eba21ce67a0ca11a793d1781b06b8078f34d06c710742e55f3eee505"}, + {file = "notebook-6.4.11.tar.gz", hash = "sha256:709b1856a564fe53054796c80e17a67262071c86bfbdfa6b96aaa346113c555a"}, ] notebook-shim = [ {file = "notebook_shim-0.1.0-py3-none-any.whl", hash = "sha256:02432d55a01139ac16e2100888aa2b56c614720cec73a27e71f40a5387e45324"}, {file = "notebook_shim-0.1.0.tar.gz", hash = "sha256:7897e47a36d92248925a2143e3596f19c60597708f7bef50d81fcd31d7263e85"}, ] numpy = [ - {file = "numpy-1.21.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:301e408a052fdcda5cdcf03021ebafc3c6ea093021bf9d1aa47c54d48bdad166"}, - {file = "numpy-1.21.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a7e8f6216f180f3fd4efb73de5d1eaefb5f5a1ee5b645c67333033e39440e63a"}, - {file = "numpy-1.21.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fc7a7d7b0ed72589fd8b8486b9b42a564f10b8762be8bd4d9df94b807af4a089"}, - {file = "numpy-1.21.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58ca1d7c8aef6e996112d0ce873ac9dfa1eaf4a1196b4ff7ff73880a09923ba7"}, - {file = "numpy-1.21.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc4b2fb01f1b4ddbe2453468ea0719f4dbb1f5caa712c8b21bb3dd1480cd30d9"}, - {file = "numpy-1.21.5-cp310-cp310-win_amd64.whl", hash = "sha256:cc1b30205d138d1005adb52087ff45708febbef0e420386f58664f984ef56954"}, - {file = "numpy-1.21.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:08de8472d9f7571f9d51b27b75e827f5296295fa78817032e84464be8bb905bc"}, - {file = "numpy-1.21.5-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4fe6a006557b87b352c04596a6e3f12a57d6e5f401d804947bd3188e6b0e0e76"}, - {file = "numpy-1.21.5-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3d893b0871322eaa2f8c7072cdb552d8e2b27645b7875a70833c31e9274d4611"}, - {file = "numpy-1.21.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:341dddcfe3b7b6427a28a27baa59af5ad51baa59bfec3264f1ab287aa3b30b13"}, - {file = "numpy-1.21.5-cp37-cp37m-win32.whl", hash = "sha256:ca9c23848292c6fe0a19d212790e62f398fd9609aaa838859be8459bfbe558aa"}, - {file = "numpy-1.21.5-cp37-cp37m-win_amd64.whl", hash = "sha256:025b497014bc33fc23897859350f284323f32a2fff7654697f5a5fc2a19e9939"}, - {file = "numpy-1.21.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3a5098df115340fb17fc93867317a947e1dcd978c3888c5ddb118366095851f8"}, - {file = "numpy-1.21.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:311283acf880cfcc20369201bd75da907909afc4666966c7895cbed6f9d2c640"}, - {file = "numpy-1.21.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b545ebadaa2b878c8630e5bcdb97fc4096e779f335fc0f943547c1c91540c815"}, - {file = "numpy-1.21.5-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c5562bcc1a9b61960fc8950ade44d00e3de28f891af0acc96307c73613d18f6e"}, - {file = "numpy-1.21.5-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:eed2afaa97ec33b4411995be12f8bdb95c87984eaa28d76cf628970c8a2d689a"}, - {file = "numpy-1.21.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61bada43d494515d5b122f4532af226fdb5ee08fe5b5918b111279843dc6836a"}, - {file = "numpy-1.21.5-cp38-cp38-win32.whl", hash = "sha256:7b9d6b14fc9a4864b08d1ba57d732b248f0e482c7b2ff55c313137e3ed4d8449"}, - {file = "numpy-1.21.5-cp38-cp38-win_amd64.whl", hash = "sha256:dbce7adeb66b895c6aaa1fad796aaefc299ced597f6fbd9ceddb0dd735245354"}, - {file = "numpy-1.21.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:507c05c7a37b3683eb08a3ff993bd1ee1e6c752f77c2f275260533b265ecdb6c"}, - {file = "numpy-1.21.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:00c9fa73a6989895b8815d98300a20ac993c49ac36c8277e8ffeaa3631c0dbbb"}, - {file = "numpy-1.21.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:69a5a8d71c308d7ef33ef72371c2388a90e3495dbb7993430e674006f94797d5"}, - {file = "numpy-1.21.5-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2d8adfca843bc46ac199a4645233f13abf2011a0b2f4affc5c37cd552626f27b"}, - {file = "numpy-1.21.5-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c293d3c0321996cd8ffe84215ffe5d269fd9d1d12c6f4ffe2b597a7c30d3e593"}, - {file = "numpy-1.21.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c978544be9e04ed12016dd295a74283773149b48f507d69b36f91aa90a643e5"}, - {file = "numpy-1.21.5-cp39-cp39-win32.whl", hash = "sha256:2a9add27d7fc0fdb572abc3b2486eb3b1395da71e0254c5552b2aad2a18b5441"}, - {file = "numpy-1.21.5-cp39-cp39-win_amd64.whl", hash = "sha256:1964db2d4a00348b7a60ee9d013c8cb0c566644a589eaa80995126eac3b99ced"}, - {file = "numpy-1.21.5-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a7c4b701ca418cd39e28ec3b496e6388fe06de83f5f0cb74794fa31cfa384c02"}, - {file = "numpy-1.21.5.zip", hash = "sha256:6a5928bc6241264dce5ed509e66f33676fc97f464e7a919edc672fb5532221ee"}, + {file = "numpy-1.21.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8737609c3bbdd48e380d463134a35ffad3b22dc56295eff6f79fd85bd0eeeb25"}, + {file = "numpy-1.21.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fdffbfb6832cd0b300995a2b08b8f6fa9f6e856d562800fea9182316d99c4e8e"}, + {file = "numpy-1.21.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3820724272f9913b597ccd13a467cc492a0da6b05df26ea09e78b171a0bb9da6"}, + {file = "numpy-1.21.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f17e562de9edf691a42ddb1eb4a5541c20dd3f9e65b09ded2beb0799c0cf29bb"}, + {file = "numpy-1.21.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f30427731561ce75d7048ac254dbe47a2ba576229250fb60f0fb74db96501a1"}, + {file = "numpy-1.21.6-cp310-cp310-win32.whl", hash = "sha256:d4bf4d43077db55589ffc9009c0ba0a94fa4908b9586d6ccce2e0b164c86303c"}, + {file = "numpy-1.21.6-cp310-cp310-win_amd64.whl", hash = "sha256:d136337ae3cc69aa5e447e78d8e1514be8c3ec9b54264e680cf0b4bd9011574f"}, + {file = "numpy-1.21.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6aaf96c7f8cebc220cdfc03f1d5a31952f027dda050e5a703a0d1c396075e3e7"}, + {file = "numpy-1.21.6-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:67c261d6c0a9981820c3a149d255a76918278a6b03b6a036800359aba1256d46"}, + {file = "numpy-1.21.6-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a6be4cb0ef3b8c9250c19cc122267263093eee7edd4e3fa75395dfda8c17a8e2"}, + {file = "numpy-1.21.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c4068a8c44014b2d55f3c3f574c376b2494ca9cc73d2f1bd692382b6dffe3db"}, + {file = "numpy-1.21.6-cp37-cp37m-win32.whl", hash = "sha256:7c7e5fa88d9ff656e067876e4736379cc962d185d5cd808014a8a928d529ef4e"}, + {file = "numpy-1.21.6-cp37-cp37m-win_amd64.whl", hash = "sha256:bcb238c9c96c00d3085b264e5c1a1207672577b93fa666c3b14a45240b14123a"}, + {file = "numpy-1.21.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:82691fda7c3f77c90e62da69ae60b5ac08e87e775b09813559f8901a88266552"}, + {file = "numpy-1.21.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:643843bcc1c50526b3a71cd2ee561cf0d8773f062c8cbaf9ffac9fdf573f83ab"}, + {file = "numpy-1.21.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:357768c2e4451ac241465157a3e929b265dfac85d9214074985b1786244f2ef3"}, + {file = "numpy-1.21.6-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9f411b2c3f3d76bba0865b35a425157c5dcf54937f82bbeb3d3c180789dd66a6"}, + {file = "numpy-1.21.6-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4aa48afdce4660b0076a00d80afa54e8a97cd49f457d68a4342d188a09451c1a"}, + {file = "numpy-1.21.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a96eef20f639e6a97d23e57dd0c1b1069a7b4fd7027482a4c5c451cd7732f4"}, + {file = "numpy-1.21.6-cp38-cp38-win32.whl", hash = "sha256:5c3c8def4230e1b959671eb959083661b4a0d2e9af93ee339c7dada6759a9470"}, + {file = "numpy-1.21.6-cp38-cp38-win_amd64.whl", hash = "sha256:bf2ec4b75d0e9356edea834d1de42b31fe11f726a81dfb2c2112bc1eaa508fcf"}, + {file = "numpy-1.21.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4391bd07606be175aafd267ef9bea87cf1b8210c787666ce82073b05f202add1"}, + {file = "numpy-1.21.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:67f21981ba2f9d7ba9ade60c9e8cbaa8cf8e9ae51673934480e45cf55e953673"}, + {file = "numpy-1.21.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee5ec40fdd06d62fe5d4084bef4fd50fd4bb6bfd2bf519365f569dc470163ab0"}, + {file = "numpy-1.21.6-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1dbe1c91269f880e364526649a52eff93ac30035507ae980d2fed33aaee633ac"}, + {file = "numpy-1.21.6-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d9caa9d5e682102453d96a0ee10c7241b72859b01a941a397fd965f23b3e016b"}, + {file = "numpy-1.21.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58459d3bad03343ac4b1b42ed14d571b8743dc80ccbf27444f266729df1d6f5b"}, + {file = "numpy-1.21.6-cp39-cp39-win32.whl", hash = "sha256:7f5ae4f304257569ef3b948810816bc87c9146e8c446053539947eedeaa32786"}, + {file = "numpy-1.21.6-cp39-cp39-win_amd64.whl", hash = "sha256:e31f0bb5928b793169b87e3d1e070f2342b22d5245c755e2b81caa29756246c3"}, + {file = "numpy-1.21.6-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dd1c8f6bd65d07d3810b90d02eba7997e32abbdf1277a481d698969e921a3be0"}, + {file = "numpy-1.21.6.zip", hash = "sha256:ecb55251139706669fdec2ff073c98ef8e9a84473e51e716211b41aa0f18e656"}, ] orjson = [ - {file = "orjson-3.6.7-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:93188a9d6eb566419ad48befa202dfe7cd7a161756444b99c4ec77faea9352a4"}, - {file = "orjson-3.6.7-cp310-cp310-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:82515226ecb77689a029061552b5df1802b75d861780c401e96ca6bc8495f775"}, - {file = "orjson-3.6.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3af57ffab7848aaec6ba6b9e9b41331250b57bf696f9d502bacdc71a0ebab0ba"}, - {file = "orjson-3.6.7-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:a7297504d1142e7efa236ffc53f056d73934a993a08646dbcee89fc4308a8fcf"}, - {file = "orjson-3.6.7-cp310-cp310-manylinux_2_24_x86_64.whl", hash = "sha256:5a50cde0dbbde255ce751fd1bca39d00ecd878ba0903c0480961b31984f2fab7"}, - {file = "orjson-3.6.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d21f9a2d1c30e58070f93988db4cad154b9009fafbde238b52c1c760e3607fbe"}, - {file = "orjson-3.6.7-cp310-none-win_amd64.whl", hash = "sha256:e152464c4606b49398afd911777decebcf9749cc8810c5b4199039e1afb0991e"}, - {file = "orjson-3.6.7-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:0a65f3c403f38b0117c6dd8e76e85a7bd51fcd92f06c5598dfeddbc44697d3e5"}, - {file = "orjson-3.6.7-cp37-cp37m-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:6c47cfca18e41f7f37b08ff3e7abf5ada2d0f27b5ade934f05be5fc5bb956e9d"}, - {file = "orjson-3.6.7-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:63185af814c243fad7a72441e5f98120c9ecddf2675befa486d669fb65539e9b"}, - {file = "orjson-3.6.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2da6fde42182b80b40df2e6ab855c55090ebfa3fcc21c182b7ad1762b61d55c"}, - {file = "orjson-3.6.7-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:48c5831ec388b4e2682d4ff56d6bfa4a2ef76c963f5e75f4ff4785f9cf338a80"}, - {file = "orjson-3.6.7-cp37-cp37m-manylinux_2_24_x86_64.whl", hash = "sha256:913fac5d594ccabf5e8fbac15b9b3bb9c576d537d49eeec9f664e7a64dde4c4b"}, - {file = "orjson-3.6.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:58f244775f20476e5851e7546df109f75160a5178d44257d437ba6d7e562bfe8"}, - {file = "orjson-3.6.7-cp37-none-win_amd64.whl", hash = "sha256:2d5f45c6b85e5f14646df2d32ecd7ff20fcccc71c0ea1155f4d3df8c5299bbb7"}, - {file = "orjson-3.6.7-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:612d242493afeeb2068bc72ff2544aa3b1e627578fcf92edee9daebb5893ffea"}, - {file = "orjson-3.6.7-cp38-cp38-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:539cdc5067db38db27985e257772d073cd2eb9462d0a41bde96da4e4e60bd99b"}, - {file = "orjson-3.6.7-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d103b721bbc4f5703f62b3882e638c0b65fcdd48622531c7ffd45047ef8e87c"}, - {file = "orjson-3.6.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb10a20f80e95102dd35dfbc3a22531661b44a09b55236b012a446955846b023"}, - {file = "orjson-3.6.7-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:bb68d0da349cf8a68971a48ad179434f75256159fe8b0715275d9b49fa23b7a3"}, - {file = "orjson-3.6.7-cp38-cp38-manylinux_2_24_x86_64.whl", hash = "sha256:4a2c7d0a236aaeab7f69c17b7ab4c078874e817da1bfbb9827cb8c73058b3050"}, - {file = "orjson-3.6.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3be045ca3b96119f592904cf34b962969ce97bd7843cbfca084009f6c8d2f268"}, - {file = "orjson-3.6.7-cp38-none-win_amd64.whl", hash = "sha256:bd765c06c359d8a814b90f948538f957fa8a1f55ad1aaffcdc5771996aaea061"}, - {file = "orjson-3.6.7-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:7dd9e1e46c0776eee9e0649e3ae9584ea368d96851bcaeba18e217fa5d755283"}, - {file = "orjson-3.6.7-cp39-cp39-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:c4b4f20a1e3df7e7c83717aff0ef4ab69e42ce2fb1f5234682f618153c458406"}, - {file = "orjson-3.6.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7107a5673fd0b05adbb58bf71c1578fc84d662d29c096eb6d998982c8635c221"}, - {file = "orjson-3.6.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a08b6940dd9a98ccf09785890112a0f81eadb4f35b51b9a80736d1725437e22c"}, - {file = "orjson-3.6.7-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:f5d1648e5a9d1070f3628a69a7c6c17634dbb0caf22f2085eca6910f7427bf1f"}, - {file = "orjson-3.6.7-cp39-cp39-manylinux_2_24_x86_64.whl", hash = "sha256:e6201494e8dff2ce7fd21da4e3f6dfca1a3fed38f9dcefc972f552f6596a7621"}, - {file = "orjson-3.6.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:70d0386abe02879ebaead2f9632dd2acb71000b4721fd8c1a2fb8c031a38d4d5"}, - {file = "orjson-3.6.7-cp39-none-win_amd64.whl", hash = "sha256:d9a3288861bfd26f3511fb4081561ca768674612bac59513cb9081bb61fcc87f"}, - {file = "orjson-3.6.7.tar.gz", hash = "sha256:a4bb62b11289b7620eead2f25695212e9ac77fcfba76f050fa8a540fb5c32401"}, + {file = "orjson-3.6.8-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:3a287a650458de2211db03681b71c3e5cb2212b62f17a39df8ad99fc54855d0f"}, + {file = "orjson-3.6.8-cp310-cp310-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:5204e25c12cea58e524fc82f7c27ed0586f592f777b33075a92ab7b3eb3687c2"}, + {file = "orjson-3.6.8-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77e8386393add64f959c044e0fb682364fd0e611a6f477aa13f0e6a733bd6a28"}, + {file = "orjson-3.6.8-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:279f2d2af393fdf8601020744cb206b91b54ad60fb8401e0761819c7bda1f4e4"}, + {file = "orjson-3.6.8-cp310-cp310-manylinux_2_24_x86_64.whl", hash = "sha256:c31c9f389be7906f978ed4192eb58a4b74a37ad60556a0b88ddc47c576697770"}, + {file = "orjson-3.6.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0db5c5a0c5b89f092d52f6e5a3701660a9d6ffa9e2968b3ce17c2bc4f5eb0414"}, + {file = "orjson-3.6.8-cp310-none-win_amd64.whl", hash = "sha256:eb22485847b9a0c4bbedc668df860126ac931edbed1d456cf41a59f3cb961ed8"}, + {file = "orjson-3.6.8-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:1a5fe569310bc819279bd4d5f2c349910b104ed3207936246dd5d5e0b085e74a"}, + {file = "orjson-3.6.8-cp37-cp37m-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:ccb356a47ab1067cd3549847e9db1d279a63fe0482d315b3ffd6e7abef35ef77"}, + {file = "orjson-3.6.8-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab29c069c222248ce302a25855b4e1664f9436e8ae5a131fb0859daf31676d2b"}, + {file = "orjson-3.6.8-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d2b5e4cba9e774ac011071d9d27760f97f4b8cd46003e971d122e712f971345"}, + {file = "orjson-3.6.8-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:c311ec504414d22834d5b972a209619925b48263856a11a14d90230f9682d49c"}, + {file = "orjson-3.6.8-cp37-cp37m-manylinux_2_24_x86_64.whl", hash = "sha256:a3dfec7950b90fb8d143743503ee53fa06b32e6068bdea792fc866284da3d71d"}, + {file = "orjson-3.6.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b890dbbada2cbb26eb29bd43a848426f007f094bb0758df10dfe7a438e1cb4b4"}, + {file = "orjson-3.6.8-cp37-none-win_amd64.whl", hash = "sha256:9143ae2c52771525be9ad11a7a8cc8e7fd75391b107e7e644a9e0050496f6b4f"}, + {file = "orjson-3.6.8-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:33a82199fd42f6436f833e210ae5129c922a5c355629356ca7a8e82964da7285"}, + {file = "orjson-3.6.8-cp38-cp38-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:90159ea8b9a5a2a98fa33dc7b421cfac4d2ae91ba5e1058f5909e7f059f6b467"}, + {file = "orjson-3.6.8-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:656fbe15d9ef0733e740d9def78f4fdb4153102f4836ee774a05123499005931"}, + {file = "orjson-3.6.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7be3be6153843e0f01351b1313a5ad4723595427680dac2dfff22a37e652ce02"}, + {file = "orjson-3.6.8-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:dd24f66b6697ee7424f7da575ec6cbffc8ede441114d53470949cda4d97c6e56"}, + {file = "orjson-3.6.8-cp38-cp38-manylinux_2_24_x86_64.whl", hash = "sha256:b07c780f7345ecf5901356dc21dee0669defc489c38ce7b9ab0f5e008cc0385c"}, + {file = "orjson-3.6.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ea32015a5d8a4ce00d348a0de5dc7040e0ad58f970a8fcbb5713a1eac129e493"}, + {file = "orjson-3.6.8-cp38-none-win_amd64.whl", hash = "sha256:c5a3e382194c838988ec128a26b08aa92044e5e055491cc4056142af0c1c54d7"}, + {file = "orjson-3.6.8-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:83a8424e857ae1bf53530e88b4eb2f16ca2b489073b924e655f1575cacd7f52a"}, + {file = "orjson-3.6.8-cp39-cp39-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:81e1a6a2d67f15007dadacbf9ba5d3d79237e5e33786c028557fe5a2b72f1c9a"}, + {file = "orjson-3.6.8-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:137b539881c77866eba86ff6a11df910daf2eb9ab8f1acae62f879e83d7c38af"}, + {file = "orjson-3.6.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cbd358f3b3ad539a27e36900e8e7d172d0e1b72ad9dd7d69544dcbc0f067ee7"}, + {file = "orjson-3.6.8-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:6ab94701542d40b90903ecfc339333f458884979a01cb9268bc662cc67a5f6d8"}, + {file = "orjson-3.6.8-cp39-cp39-manylinux_2_24_x86_64.whl", hash = "sha256:32b6f26593a9eb606b40775826beb0dac152e3d224ea393688fced036045a821"}, + {file = "orjson-3.6.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:afd9e329ebd3418cac3cd747769b1d52daa25fa672bbf414ab59f0e0881b32b9"}, + {file = "orjson-3.6.8-cp39-none-win_amd64.whl", hash = "sha256:0c89b419914d3d1f65a1b0883f377abe42a6e44f6624ba1c63e8846cbfc2fa60"}, + {file = "orjson-3.6.8.tar.gz", hash = "sha256:e19d23741c5de13689bb316abfccea15a19c264e3ec8eb332a5319a583595ace"}, ] outcome = [ {file = "outcome-1.1.0-py2.py3-none-any.whl", hash = "sha256:c7dd9375cfd3c12db9801d080a3b63d4b0a261aa996c4c13152380587288d958"}, @@ -2597,20 +2745,20 @@ pickleshare = [ {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, ] platformdirs = [ - {file = "platformdirs-2.5.1-py3-none-any.whl", hash = "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"}, - {file = "platformdirs-2.5.1.tar.gz", hash = "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d"}, + {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, + {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, ] plotly = [ - {file = "plotly-5.6.0-py2.py3-none-any.whl", hash = "sha256:20277d211ea0e00e2a86d31e9f865a1ab45a7b17576f3bb865992ecbf15db093"}, - {file = "plotly-5.6.0.tar.gz", hash = "sha256:d86e44ebde38f4753dff982ab9b5e03cf872aab8fdf53a403e999ed378154331"}, + {file = "plotly-5.7.0-py2.py3-none-any.whl", hash = "sha256:3a35131762c6567813012462e1d496e1d3898f56ab3d386b32f103f7f0c79cf1"}, + {file = "plotly-5.7.0.tar.gz", hash = "sha256:15ab20e9ed8b55f669b3d35e186eb48f9e1fe07321a1337b8b7df8d3573d265a"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] prometheus-client = [ - {file = "prometheus_client-0.13.1-py3-none-any.whl", hash = "sha256:357a447fd2359b0a1d2e9b311a0c5778c330cfbe186d880ad5a6b39884652316"}, - {file = "prometheus_client-0.13.1.tar.gz", hash = "sha256:ada41b891b79fca5638bd5cfe149efa86512eaa55987893becd2c6d8d0a5dfc5"}, + {file = "prometheus_client-0.14.1-py3-none-any.whl", hash = "sha256:522fded625282822a89e2773452f42df14b5a8e84a86433e3f8a189c1d54dc01"}, + {file = "prometheus_client-0.14.1.tar.gz", hash = "sha256:5459c427624961076277fdc6dc50540e2bacb98eebde99886e59ec55ed92093a"}, ] prompt-toolkit = [ {file = "prompt_toolkit-3.0.29-py3-none-any.whl", hash = "sha256:62291dad495e665fca0bda814e342c69952086afb0f4094d0893d357e5c78752"}, @@ -2728,16 +2876,16 @@ pyfunctional = [ {file = "PyFunctional-1.4.3.tar.gz", hash = "sha256:11c313fe251b269c6506689135456a05bc5a6fa129c9d02b445f383d4f411e10"}, ] pygments = [ - {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, - {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, + {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"}, + {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"}, ] pyopenssl = [ {file = "pyOpenSSL-22.0.0-py2.py3-none-any.whl", hash = "sha256:ea252b38c87425b64116f808355e8da644ef9b07e429398bfece610f893ee2e0"}, {file = "pyOpenSSL-22.0.0.tar.gz", hash = "sha256:660b1b1425aac4a1bea1d94168a85d99f0b3144c869dd4390d27629d0087f1bf"}, ] pyparsing = [ - {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, - {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, + {file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, + {file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, ] pyrsistent = [ {file = "pyrsistent-0.18.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:df46c854f490f81210870e509818b729db4488e1f30f2a1ce1698b2295a878d1"}, @@ -2908,16 +3056,16 @@ sortedcontainers = [ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, ] soupsieve = [ - {file = "soupsieve-2.3.1-py3-none-any.whl", hash = "sha256:1a3cca2617c6b38c0343ed661b1fa5de5637f257d4fe22bd9f1338010a1efefb"}, - {file = "soupsieve-2.3.1.tar.gz", hash = "sha256:b8d49b1cd4f037c7082a9683dfa1801aa2597fb11c3a1155b7a5b94829b4f1f9"}, + {file = "soupsieve-2.3.2.post1-py3-none-any.whl", hash = "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759"}, + {file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"}, ] sphinx = [ {file = "Sphinx-4.5.0-py3-none-any.whl", hash = "sha256:ebf612653238bcc8f4359627a9b7ce44ede6fdd75d9d30f68255c7383d3a6226"}, {file = "Sphinx-4.5.0.tar.gz", hash = "sha256:7bf8ca9637a4ee15af412d1a1d9689fec70523a68ca9bb9127c2f3eeb344e2e6"}, ] sphinx-autodoc-typehints = [ - {file = "sphinx_autodoc_typehints-1.17.0-py3-none-any.whl", hash = "sha256:081daf53077b4ae1c28347d6d858e13e63aefe3b4aacef79fd717dd60687b470"}, - {file = "sphinx_autodoc_typehints-1.17.0.tar.gz", hash = "sha256:51c7b3f5cb9ccd15d0b52088c62df3094f1abd9612930340365c26def8629a14"}, + {file = "sphinx_autodoc_typehints-1.18.1-py3-none-any.whl", hash = "sha256:f8f5bb7c13a9a71537dc2be2eb3b9e28a9711e2454df63587005eacf6fbac453"}, + {file = "sphinx_autodoc_typehints-1.18.1.tar.gz", hash = "sha256:07631c5f0c6641e5ba27143494aefc657e029bed3982138d659250e617f6f929"}, ] sphinxcontrib-applehelp = [ {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, @@ -2955,9 +3103,9 @@ terminado = [ {file = "terminado-0.13.3-py3-none-any.whl", hash = "sha256:874d4ea3183536c1782d13c7c91342ef0cf4e5ee1d53633029cbc972c8760bd8"}, {file = "terminado-0.13.3.tar.gz", hash = "sha256:94d1cfab63525993f7d5c9b469a50a18d0cdf39435b59785715539dd41e36c0d"}, ] -testpath = [ - {file = "testpath-0.6.0-py3-none-any.whl", hash = "sha256:8ada9f80a2ac6fb0391aa7cdb1a7d11cfa8429f693eda83f74dde570fe6fa639"}, - {file = "testpath-0.6.0.tar.gz", hash = "sha256:2f1b97e6442c02681ebe01bd84f531028a7caea1af3825000f52345c30285e0f"}, +tinycss2 = [ + {file = "tinycss2-1.1.1-py3-none-any.whl", hash = "sha256:fe794ceaadfe3cf3e686b22155d0da5780dd0e273471a51846d0a02bc204fec8"}, + {file = "tinycss2-1.1.1.tar.gz", hash = "sha256:b2e44dd8883c360c35dd0d1b5aad0b610e5156c2cb3b33434634e539ead9d8bf"}, ] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, @@ -3027,34 +3175,34 @@ trio-websocket = [ {file = "trio_websocket-0.9.2-py3-none-any.whl", hash = "sha256:5b558f6e83cc20a37c3b61202476c5295d1addf57bd65543364e0337e37ed2bc"}, ] typed-ast = [ - {file = "typed_ast-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:183b183b7771a508395d2cbffd6db67d6ad52958a5fdc99f450d954003900266"}, - {file = "typed_ast-1.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:676d051b1da67a852c0447621fdd11c4e104827417bf216092ec3e286f7da596"}, - {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc2542e83ac8399752bc16e0b35e038bdb659ba237f4222616b4e83fb9654985"}, - {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74cac86cc586db8dfda0ce65d8bcd2bf17b58668dfcc3652762f3ef0e6677e76"}, - {file = "typed_ast-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:18fe320f354d6f9ad3147859b6e16649a0781425268c4dde596093177660e71a"}, - {file = "typed_ast-1.5.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:31d8c6b2df19a777bc8826770b872a45a1f30cfefcfd729491baa5237faae837"}, - {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:963a0ccc9a4188524e6e6d39b12c9ca24cc2d45a71cfdd04a26d883c922b4b78"}, - {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0eb77764ea470f14fcbb89d51bc6bbf5e7623446ac4ed06cbd9ca9495b62e36e"}, - {file = "typed_ast-1.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:294a6903a4d087db805a7656989f613371915fc45c8cc0ddc5c5a0a8ad9bea4d"}, - {file = "typed_ast-1.5.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26a432dc219c6b6f38be20a958cbe1abffcc5492821d7e27f08606ef99e0dffd"}, - {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7407cfcad702f0b6c0e0f3e7ab876cd1d2c13b14ce770e412c0c4b9728a0f88"}, - {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f30ddd110634c2d7534b2d4e0e22967e88366b0d356b24de87419cc4410c41b7"}, - {file = "typed_ast-1.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8c08d6625bb258179b6e512f55ad20f9dfef019bbfbe3095247401e053a3ea30"}, - {file = "typed_ast-1.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:90904d889ab8e81a956f2c0935a523cc4e077c7847a836abee832f868d5c26a4"}, - {file = "typed_ast-1.5.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bbebc31bf11762b63bf61aaae232becb41c5bf6b3461b80a4df7e791fabb3aca"}, - {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29dd9a3a9d259c9fa19d19738d021632d673f6ed9b35a739f48e5f807f264fb"}, - {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:58ae097a325e9bb7a684572d20eb3e1809802c5c9ec7108e85da1eb6c1a3331b"}, - {file = "typed_ast-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:da0a98d458010bf4fe535f2d1e367a2e2060e105978873c04c04212fb20543f7"}, - {file = "typed_ast-1.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:33b4a19ddc9fc551ebabca9765d54d04600c4a50eda13893dadf67ed81d9a098"}, - {file = "typed_ast-1.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1098df9a0592dd4c8c0ccfc2e98931278a6c6c53cb3a3e2cf7e9ee3b06153344"}, - {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c47c3b43fe3a39ddf8de1d40dbbfca60ac8530a36c9b198ea5b9efac75c09e"}, - {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f290617f74a610849bd8f5514e34ae3d09eafd521dceaa6cf68b3f4414266d4e"}, - {file = "typed_ast-1.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:df05aa5b241e2e8045f5f4367a9f6187b09c4cdf8578bb219861c4e27c443db5"}, - {file = "typed_ast-1.5.2.tar.gz", hash = "sha256:525a2d4088e70a9f75b08b3f87a51acc9cde640e19cc523c7e41aa355564ae27"}, + {file = "typed_ast-1.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ad3b48cf2b487be140072fb86feff36801487d4abb7382bb1929aaac80638ea"}, + {file = "typed_ast-1.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:542cd732351ba8235f20faa0fc7398946fe1a57f2cdb289e5497e1e7f48cfedb"}, + {file = "typed_ast-1.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc2c11ae59003d4a26dda637222d9ae924387f96acae9492df663843aefad55"}, + {file = "typed_ast-1.5.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd5df1313915dbd70eaaa88c19030b441742e8b05e6103c631c83b75e0435ccc"}, + {file = "typed_ast-1.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:e34f9b9e61333ecb0f7d79c21c28aa5cd63bec15cb7e1310d7d3da6ce886bc9b"}, + {file = "typed_ast-1.5.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f818c5b81966d4728fec14caa338e30a70dfc3da577984d38f97816c4b3071ec"}, + {file = "typed_ast-1.5.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3042bfc9ca118712c9809201f55355479cfcdc17449f9f8db5e744e9625c6805"}, + {file = "typed_ast-1.5.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4fff9fdcce59dc61ec1b317bdb319f8f4e6b69ebbe61193ae0a60c5f9333dc49"}, + {file = "typed_ast-1.5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:8e0b8528838ffd426fea8d18bde4c73bcb4167218998cc8b9ee0a0f2bfe678a6"}, + {file = "typed_ast-1.5.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ef1d96ad05a291f5c36895d86d1375c0ee70595b90f6bb5f5fdbee749b146db"}, + {file = "typed_ast-1.5.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed44e81517364cb5ba367e4f68fca01fba42a7a4690d40c07886586ac267d9b9"}, + {file = "typed_ast-1.5.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f60d9de0d087454c91b3999a296d0c4558c1666771e3460621875021bf899af9"}, + {file = "typed_ast-1.5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9e237e74fd321a55c90eee9bc5d44be976979ad38a29bbd734148295c1ce7617"}, + {file = "typed_ast-1.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ee852185964744987609b40aee1d2eb81502ae63ee8eef614558f96a56c1902d"}, + {file = "typed_ast-1.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:27e46cdd01d6c3a0dd8f728b6a938a6751f7bd324817501c15fb056307f918c6"}, + {file = "typed_ast-1.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d64dabc6336ddc10373922a146fa2256043b3b43e61f28961caec2a5207c56d5"}, + {file = "typed_ast-1.5.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8cdf91b0c466a6c43f36c1964772918a2c04cfa83df8001ff32a89e357f8eb06"}, + {file = "typed_ast-1.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:9cc9e1457e1feb06b075c8ef8aeb046a28ec351b1958b42c7c31c989c841403a"}, + {file = "typed_ast-1.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e20d196815eeffb3d76b75223e8ffed124e65ee62097e4e73afb5fec6b993e7a"}, + {file = "typed_ast-1.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:37e5349d1d5de2f4763d534ccb26809d1c24b180a477659a12c4bde9dd677d74"}, + {file = "typed_ast-1.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9f1a27592fac87daa4e3f16538713d705599b0a27dfe25518b80b6b017f0a6d"}, + {file = "typed_ast-1.5.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8831479695eadc8b5ffed06fdfb3e424adc37962a75925668deeb503f446c0a3"}, + {file = "typed_ast-1.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:20d5118e494478ef2d3a2702d964dae830aedd7b4d3b626d003eea526be18718"}, + {file = "typed_ast-1.5.3.tar.gz", hash = "sha256:27f25232e2dd0edfe1f019d6bfaaf11e86e657d9bdb7b0956db95f560cceb2b3"}, ] typing-extensions = [ - {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, - {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, + {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"}, + {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"}, ] urllib3 = [ {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, @@ -3080,6 +3228,10 @@ werkzeug = [ {file = "Werkzeug-2.1.1-py3-none-any.whl", hash = "sha256:3c5493ece8268fecdcdc9c0b112211acd006354723b280d643ec732b6d4063d6"}, {file = "Werkzeug-2.1.1.tar.gz", hash = "sha256:f8e89a20aeabbe8a893c24a461d3ee5dad2123b05cc6abd73ceed01d39c3ae74"}, ] +widgetsnbextension = [ + {file = "widgetsnbextension-3.6.0-py2.py3-none-any.whl", hash = "sha256:4fd321cad39fdcf8a8e248a657202d42917ada8e8ed5dd3f60f073e0d54ceabd"}, + {file = "widgetsnbextension-3.6.0.tar.gz", hash = "sha256:e84a7a9fcb9baf3d57106e184a7389a8f8eb935bf741a5eb9d60aa18cc029a80"}, +] wsproto = [ {file = "wsproto-1.1.0-py3-none-any.whl", hash = "sha256:2218cb57952d90b9fca325c0dcfb08c3bda93e8fd8070b0a17f048e2e47a521b"}, {file = "wsproto-1.1.0.tar.gz", hash = "sha256:a2e56bfd5c7cd83c1369d83b5feccd6d37798b74872866e62616e0ecf111bda8"}, diff --git a/pyproject.toml b/pyproject.toml index 9905f867..eb0b3b6d 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.4.0" +version = "0.5.0" description = "Visualizing large time series with plotly" authors = ["Jonas Van Der Donckt", "Jeroen Van Der Donckt", "Emiel Deprost"] readme = "README.md" @@ -14,16 +14,16 @@ jupyter-dash = ">=0.4.2" plotly = "^5.6.0" dash = "^2.2.0" lttbc = "0.2.0" -orjson = "^3.6.7" +orjson = "^3.6.8" pandas = "^1.3.5" trace-updater = ">=0.0.8" [tool.poetry.dev-dependencies] -jupyterlab = "^3.2.0" +jupyterlab = "^3.3.0" numpy = "^1.21.2" pytest = "^6.2.5" pytest-cov = "^3.0.0" -black = {version = "^21.11b1", allow-prereleases = true} +black = "^22.3.0" selenium = "^4.1.0" pytest-selenium = "^2.0.1" webdriver-manager = "^3.5.2" @@ -34,6 +34,9 @@ dash-bootstrap-components = "^1.0.3" Sphinx = "^4.4.0" pydata-sphinx-theme = "^0.8.0" sphinx-autodoc-typehints = "^1.17.0" +ipywidgets = "^7.7.0" +memory-profiler = "^0.60.0" +line-profiler = "^3.5.1" [build-system] requires = ["poetry-core>=1.0.0"] From 1d70f0eb76025eae3602d0338592794aa642f141 Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Tue, 3 May 2022 21:37:14 +0200 Subject: [PATCH 09/19] :heavy_check_mark: --- tests/test_figurewidget_resampler.py | 348 ++++++++++++++++++++++++++- 1 file changed, 347 insertions(+), 1 deletion(-) diff --git a/tests/test_figurewidget_resampler.py b/tests/test_figurewidget_resampler.py index d4d88e52..6a874e9b 100644 --- a/tests/test_figurewidget_resampler.py +++ b/tests/test_figurewidget_resampler.py @@ -6,6 +6,7 @@ import pytest import numpy as np import pandas as pd +from copy import copy import plotly.graph_objects as go from plotly.subplots import make_subplots from plotly_resampler import FigureWidgetResampler, EfficientLTTB, EveryNthPoint @@ -323,7 +324,9 @@ def test_proper_copy_of_wrapped_fig(float_series): ) ) - plotly_resampler_fig = FigureWidgetResampler(plotly_fig, default_n_shown_samples=500) + plotly_resampler_fig = FigureWidgetResampler( + plotly_fig, default_n_shown_samples=500 + ) assert len(plotly_fig.data) == 1 assert all(plotly_fig.data[0].x == float_series.index) @@ -502,3 +505,346 @@ def test_hf_data_property(): assert len(fr.hf_data) == 1 assert len(fr.hf_data[0]["x"]) == n fr.hf_data[0] = -2 * y + + +def test_updates_two_traces(): + n = 1_000_000 + X = np.arange(n) + Y = np.random.rand(n) / 5 + np.sin(np.arange(n) / 10000) + + fw_fig = FigureWidgetResampler( + make_subplots(rows=2, shared_xaxes=False), verbose=True + ) + fw_fig.update_layout(height=400, showlegend=True) + + fw_fig.add_trace(go.Scattergl(), hf_x=X, hf_y=(Y + 90) * X / 2000, row=1, col=1) + fw_fig.add_trace(go.Scattergl(), hf_x=X, hf_y=(Y + 3) * 0.99999**X, row=2, col=1) + + # we do not want to have an relayout update + assert len(fw_fig._relayout_hist) == 0 + + # zoom in on both traces + fw_fig.layout.update( + {"xaxis": {"range": [10_000, 200_000]}, "xaxis2": {"range": [0, 200_000]}}, + overwrite=False, + ) + + # check whether the two traces were updated with the xaxis-range method + assert ["xaxis-range-update", 2] in fw_fig._relayout_hist + assert sum([["xaxis-range-update", 2] == rh for rh in fw_fig._relayout_hist]) == 1 + # check whether the showspikes update was did not enter the update state + assert ( + sum( + [ + "showspikes-update" in rh if isinstance(rh, list) else False + for rh in fw_fig._relayout_hist + ] + ) + == 0 + ) + + # apply an autorange, see whether an update takes place + fw_fig._relayout_hist.clear() + fw_fig.layout.update({"xaxis": {"autorange": True}}) + fw_fig.layout.update({"xaxis2": {"autorange": True}}) + + assert len(fw_fig._relayout_hist) == 0 + + # Perform a reset axis update + fw_fig.layout.update( + { + "xaxis": {"autorange": True, "showspikes": False}, + "xaxis2": {"autorange": True, "showspikes": False}, + } + ) + + # check whether the two traces were updated with the showspike method + assert ["showspikes-update", 2] in fw_fig._relayout_hist + assert sum([["showspikes-update", 2] == rh for rh in fw_fig._relayout_hist]) == 1 + # check whether the xaxis-range-update was did not enter the update state + assert ( + sum( + [ + "xaxis-range-update" in rh if isinstance(rh, list) else False + for rh in fw_fig._relayout_hist + ] + ) + == 0 + ) + + # RE-perform a reset axis update + fw_fig._relayout_hist.clear() + fw_fig.layout.update( + { + "xaxis": {"autorange": True, "showspikes": False}, + "xaxis2": {"autorange": True, "showspikes": False}, + } + ) + + # check whether none of the traces we updated with the showspike method + assert ["showspikes-update", 1] not in fw_fig._relayout_hist + assert ["showspikes-update", 2] not in fw_fig._relayout_hist + # check whether the xaxis-range-update was did not enter the update state + assert ( + sum( + [ + "xaxis-range-update" in rh if isinstance(rh, list) else False + for rh in fw_fig._relayout_hist + ] + ) + == 0 + ) + + +def test_updates_two_traces_single_trace_adjust(): + n = 1_000_000 + X = np.arange(n) + Y = np.random.rand(n) / 5 + np.sin(np.arange(n) / 10000) + + fw_fig = FigureWidgetResampler( + make_subplots(rows=2, shared_xaxes=False), verbose=True + ) + fw_fig.update_layout(height=400, showlegend=True) + + fw_fig.add_trace(go.Scattergl(), hf_x=X, hf_y=(Y + 90) * X / 2000, row=1, col=1) + fw_fig.add_trace(go.Scattergl(), hf_x=X, hf_y=(Y + 3) * 0.99999**X, row=2, col=1) + + # we do not want to have an relayout update + assert len(fw_fig._relayout_hist) == 0 + + # zoom in on both traces + fw_fig.layout.update( + {"xaxis2": {"range": [0, 200_000]}}, + overwrite=False, + ) + + # check whether the single traces were updated with the xaxis-range method + assert ["xaxis-range-update", 1] in fw_fig._relayout_hist + assert ["xaxis-range-update", 2] not in fw_fig._relayout_hist + assert sum([["xaxis-range-update", 1] == rh for rh in fw_fig._relayout_hist]) == 1 + + # check whether the showspikes update was did not enter the update state + assert ( + sum( + [ + "showspikes-update" in rh if isinstance(rh, list) else False + for rh in fw_fig._relayout_hist + ] + ) + == 0 + ) + + fw_fig._relayout_hist.clear() + + # apply an autorange, see whether an update takes place + fw_fig.layout.update({"xaxis": {"autorange": True}}) + fw_fig.layout.update({"xaxis2": {"autorange": True}}) + + assert len(fw_fig._relayout_hist) == 0 + + # Perform a reset axis update + fw_fig.layout.update( + { + "xaxis": {"autorange": True, "showspikes": False}, + "xaxis2": {"autorange": True, "showspikes": False}, + } + ) + + # check whether the single traces was updated with the showspike method + assert ["showspikes-update", 1] in fw_fig._relayout_hist + assert not ["showspikes-update", 2] in fw_fig._relayout_hist + assert sum([["showspikes-update", 1] == rh for rh in fw_fig._relayout_hist]) == 1 + # check whether the xaxis-range-update was did not enter the update state + assert ( + sum( + [ + "xaxis-range-update" in rh if isinstance(rh, list) else False + for rh in fw_fig._relayout_hist + ] + ) + == 0 + ) + + fw_fig._relayout_hist.clear() + + # RE-perform a reset axis update + # + fw_fig.layout.update( + { + "xaxis": {"autorange": True, "showspikes": False}, + "xaxis2": {"autorange": True, "showspikes": False}, + } + ) + + # check whether none of the traces we updated with the showspike method + assert ["showspikes-update", 1] not in fw_fig._relayout_hist + assert ["showspikes-update", 2] not in fw_fig._relayout_hist + # check whether the xaxis-range-update was did not enter the update state + assert ( + sum( + [ + "xaxis-range-update" in rh if isinstance(rh, list) else False + for rh in fw_fig._relayout_hist + ] + ) + == 0 + ) + + +def test_update_direct_reset_axis(): + n = 1_000_000 + X = np.arange(n) + Y = np.random.rand(n) / 5 + np.sin(np.arange(n) / 10000) + + fw_fig = FigureWidgetResampler( + make_subplots(rows=2, shared_xaxes=False), verbose=True + ) + fw_fig.update_layout(height=400, showlegend=True) + + fw_fig.add_trace(go.Scattergl(), hf_x=X, hf_y=(Y + 90) * X / 2000, row=1, col=1) + fw_fig.add_trace(go.Scattergl(), hf_x=X, hf_y=(Y + 3) * 0.99999**X, row=2, col=1) + + # we do not want to have an relayout update + assert len(fw_fig._relayout_hist) == 0 + + # Perform a reset_axis + fw_fig.layout.update( + { + "xaxis": {"autorange": True, "showspikes": False}, + "xaxis2": {"autorange": True, "showspikes": False}, + } + ) + + # check whether the two traces was updated with the showspike method + assert ["showspikes-update", 1] not in fw_fig._relayout_hist + assert ["showspikes-update", 2] not in fw_fig._relayout_hist + assert sum([["showspikes-update", 1] == rh for rh in fw_fig._relayout_hist]) == 0 + # check whether the xaxis-range-update was did not enter the update state + assert ( + sum( + [ + "xaxis-range-update" in rh if isinstance(rh, list) else False + for rh in fw_fig._relayout_hist + ] + ) + == 0 + ) + + +def test_bare_update_methods(): + n = 1_000_000 + X = np.arange(n) + Y = np.random.rand(n) / 5 + np.sin(np.arange(n) / 10000) + + fw_fig = FigureWidgetResampler( + make_subplots(rows=2, shared_xaxes=False), verbose=True + ) + fw_fig.update_layout(height=400, showlegend=True) + + fw_fig.add_trace(go.Scattergl(), hf_x=X, hf_y=(Y + 90) * X / 2000, row=1, col=1) + fw_fig.add_trace(go.Scattergl(), hf_x=X, hf_y=(Y + 3) * 0.99999**X, row=2, col=1) + + # equivalent of calling the reset-axis dict update + fw_fig._update_spike_ranges(fw_fig.layout, False, False) + fw_fig._update_spike_ranges(fw_fig.layout, False, False) + + assert ["showspikes-update", 1] not in fw_fig._relayout_hist + assert ["showspikes-update", 2] not in fw_fig._relayout_hist + assert sum([["showspikes-update", 1] == rh for rh in fw_fig._relayout_hist]) == 0 + + # check whether the xaxis-range-update was did not enter the update state + assert ( + sum( + [ + "xaxis-range-update" in rh if isinstance(rh, list) else False + for rh in fw_fig._relayout_hist + ] + ) + == 0 + ) + + fw_fig._relayout_hist.clear() + + # Zoom in on the xaxis2 + fw_fig._update_x_ranges( + copy(fw_fig.layout).update( + {"xaxis2": {"range": [0, 200_000]}}, + overwrite=True, + ), + (0, len(X)), + (0, 200_000), + ) + + # check whether the single traces were updated with the xaxis-range method + assert ["xaxis-range-update", 1] in fw_fig._relayout_hist + assert ["xaxis-range-update", 2] not in fw_fig._relayout_hist + assert sum([["xaxis-range-update", 1] == rh for rh in fw_fig._relayout_hist]) == 1 + + # check whether the showspikes update was did not enter the update state + assert ( + sum( + [ + "showspikes-update" in rh if isinstance(rh, list) else False + for rh in fw_fig._relayout_hist + ] + ) + == 0 + ) + + # check whether the new update call (on the same range) does nothing + fw_fig._relayout_hist.clear() + fw_fig._update_x_ranges( + copy(fw_fig.layout).update( + {"xaxis2": {"range": [0, 200_000]}}, + overwrite=True, + ), + (0, len(X)), + (0, 200_000), + ) + + # check whether none of the traces we updated with the showspike method + assert ["showspikes-update", 1] not in fw_fig._relayout_hist + assert ["showspikes-update", 2] not in fw_fig._relayout_hist + # check whether the xaxis-range-update was did not enter the update state + assert ( + sum( + [ + "xaxis-range-update" in rh if isinstance(rh, list) else False + for rh in fw_fig._relayout_hist + ] + ) + == 0 + ) + + # Perform an autorange udpate -> assert that the range i + fw_fig._relayout_hist.clear() + fw_fig.layout.update({"xaxis2": {"autorange": True}, "yaxis2": {"autorange": True}}) + assert len(fw_fig._relayout_hist) == 0 + + fw_fig.layout.update({"yaxis2": {"range": [0, 2]}}) + assert len(fw_fig._relayout_hist) == 0 + + # perform an reset axis + fw_fig._relayout_hist.clear() + l = fw_fig.layout.update( + { + "xaxis": {"autorange": True, "showspikes": False}, + "xaxis2": {"autorange": True, "showspikes": False}, + }, + overwrite=True, # by setting this to true -> the update call will not takte clear + ) + fw_fig._update_spike_ranges(l, False, False) + + # Assert that only a single trace was updated + assert ["showspikes-update", 1] in fw_fig._relayout_hist + assert ["showspikes-update", 2] not in fw_fig._relayout_hist + # check whether the xaxis-range-update was did not enter the update state + assert ( + sum( + [ + "xaxis-range-update" in rh if isinstance(rh, list) else False + for rh in fw_fig._relayout_hist + ] + ) + == 0 + ) From d613d3ff57a63bf50dab8187e8a605b0cefb4c63 Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Tue, 3 May 2022 21:37:56 +0200 Subject: [PATCH 10/19] :goat: add based case to update method --- plotly_resampler/figure_resampler.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plotly_resampler/figure_resampler.py b/plotly_resampler/figure_resampler.py index 29f94049..a508c89b 100644 --- a/plotly_resampler/figure_resampler.py +++ b/plotly_resampler/figure_resampler.py @@ -1050,7 +1050,7 @@ def _update_x_ranges(self, layout, *x_ranges): if self._print_verbose: self._relayout_hist.append(dict(zip(self._xaxis_list, x_ranges))) self._relayout_hist.append(layout) - self._relayout_hist.append(["xaxis-range", len(update_data) - 1]) + self._relayout_hist.append(["xaxis-range-update", len(update_data) - 1]) self._relayout_hist.append("-" * 30) with self.batch_update(): @@ -1085,6 +1085,9 @@ def _update_spike_ranges(self, layout, *showspikes): for xaxis_str in self._xaxis_list } + if self._prev_layout is None: + return + for xaxis_str, showspike in zip(self._xaxis_list, showspikes): if ( # autorange key must be set to True @@ -1107,7 +1110,7 @@ def _update_spike_ranges(self, layout, *showspikes): update_data = self.construct_update_data(relayout_dict) if self._print_verbose: self._relayout_hist.append(layout) - self._relayout_hist.append(["showspikes", len(update_data) - 1]) + self._relayout_hist.append(["showspikes-update", len(update_data) - 1]) self._relayout_hist.append("-" * 30) with self.batch_update(): @@ -1129,8 +1132,8 @@ def _update_spike_ranges(self, layout, *showspikes): self._relayout_hist.append(["showspikes", "initial call or showspikes"]) self._relayout_hist.append("-" * 40) - def show(self, *args, **kwargs): - super(go.FigureWidget, self).show(*args, **kwargs) + # def show(self, *args, **kwargs): + # super(go.FigureWidget, self).show(*args, **kwargs) class FigureResampler(AbstractFigureAggregator, go.Figure): From cdfcf899df6e89b64f23982145585ec250cc9d06 Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Wed, 4 May 2022 17:18:58 +0200 Subject: [PATCH 11/19] :card_file_box: adjusting code folder structure --- plotly_resampler/figure_resampler/__init__.py | 19 + .../figure_resampler/figure_resampler.py | 150 ++++++++ .../figure_resampler_interface.py} | 328 +----------------- .../figurewidget_resampler.py | 203 +++++++++++ 4 files changed, 375 insertions(+), 325 deletions(-) create mode 100644 plotly_resampler/figure_resampler/__init__.py create mode 100644 plotly_resampler/figure_resampler/figure_resampler.py rename plotly_resampler/{figure_resampler.py => figure_resampler/figure_resampler_interface.py} (74%) create mode 100644 plotly_resampler/figure_resampler/figurewidget_resampler.py diff --git a/plotly_resampler/figure_resampler/__init__.py b/plotly_resampler/figure_resampler/__init__.py new file mode 100644 index 00000000..a9cdc75f --- /dev/null +++ b/plotly_resampler/figure_resampler/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +Module withholdingg wrappers for the plotly ``go.Figure`` and ``go.FigureWidget`` class +which allows bookkeeping and back-end based resampling of high-frequency sequential data. + +Tip +--- +The term `high-frequency` actually refers very large amounts of sequential data. + +""" + +from .figure_resampler import FigureResampler +from .figurewidget_resampler import FigureWidgetResampler + + +__all__ = [ + "FigureResampler", + "FigureWidgetResampler", +] diff --git a/plotly_resampler/figure_resampler/figure_resampler.py b/plotly_resampler/figure_resampler/figure_resampler.py new file mode 100644 index 00000000..72861ee9 --- /dev/null +++ b/plotly_resampler/figure_resampler/figure_resampler.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +""" +``FigureResampler`` wrapper around the plotly ``go.Figure`` class. + +""" + +from __future__ import annotations + +__author__ = "Jonas Van Der Donckt, Jeroen Van Der Donckt, Emiel Deprost" + +import warnings +from typing import Tuple + +import dash +import plotly.graph_objects as go +from dash import Dash +from jupyter_dash import JupyterDash +from trace_updater import TraceUpdater + +from .figure_resampler_interface import AbstractFigureAggregator +from ..aggregation import AbstractSeriesAggregator, EfficientLTTB + + +class FigureResampler(AbstractFigureAggregator, go.Figure): + """Data aggregation functionality for ``go.Figures``.""" + + def __init__( + self, + figure: go.Figure = go.Figure(), + convert_existing_traces: bool = True, + default_n_shown_samples: int = 1000, + default_downsampler: AbstractSeriesAggregator = EfficientLTTB(), + resampled_trace_prefix_suffix: Tuple[str, str] = ( + '[R] ', + "", + ), + show_mean_aggregation_size: bool = True, + verbose: bool = False, + ): + assert isinstance(figure, go.Figure) + super().__init__( + figure, + convert_existing_traces, + default_n_shown_samples, + default_downsampler, + resampled_trace_prefix_suffix, + show_mean_aggregation_size, + verbose, + ) + + # The figureAggregator needs a dash app + self._app: JupyterDash | Dash | None = None + self._port: int | None = None + self._host: str | None = None + + def show_dash( + self, + mode=None, + config: dict | None = None, + graph_properties: dict | None = None, + **kwargs, + ): + """Registers the :func:`update_graph` callback & show the figure in a dash app. + + Parameters + ---------- + mode: str, optional + Display mode. One of:\n + * ``"external"``: The URL of the app will be displayed in the notebook + output cell. Clicking this URL will open the app in the default + web browser. + * ``"inline"``: The app will be displayed inline in the notebook output + cell in an iframe. + * ``"jupyterlab"``: The app will be displayed in a dedicated tab in the + JupyterLab interface. Requires JupyterLab and the ``jupyterlab-dash`` + extension. + By default None, which will result in the same behavior as ``"external"``. + config: dict, optional + The configuration options for displaying this figure, by default None. + This ``config`` parameter is the same as the dict that you would pass as + ``config`` argument to the `show` method. + See more https://plotly.com/python/configuration-options/ + graph_properties: dict, optional + Dictionary of (keyword, value) for the properties that should be passed to + the dcc.Graph, by default None. + e.g.: {"style": {"width": "50%"}} + Note: "config" is not allowed as key in this dict, as there is a distinct + ``config`` parameter for this property in this method. + See more https://dash.plotly.com/dash-core-components/graph + **kwargs: dict + Additional app.run_server() kwargs. + e.g.: port + + """ + graph_properties = {} if graph_properties is None else graph_properties + assert "config" not in graph_properties.keys() # There is a param for config + # 1. Construct the Dash app layout + app = JupyterDash("local_app") # TODO -> was jupyterdash + app.layout = dash.html.Div( + [ + dash.dcc.Graph( + id="resample-figure", figure=self, config=config, **graph_properties + ), + TraceUpdater( + id="trace-updater", gdID="resample-figure", sequentialUpdate=False + ), + ] + ) + self.register_update_graph_callback(app, "resample-figure", "trace-updater") + + # 2. Run the app + if ( + self.layout.height is not None + and mode == "inline" + and "height" not in kwargs + ): + # If figure height is specified -> re-use is for inline dash app height + kwargs["height"] = self.layout.height + 18 + + # store the app information, so it can be killed + self._app = app + self._host = kwargs.get("host", "127.0.0.1") + self._port = kwargs.get("port", "8050") + + app.run_server(mode=mode, **kwargs) + + def stop_server(self, warn: bool = True): + """Stop the running dash-app. + + Parameters + ---------- + warn: bool + Whether a warning message will be shown or not, by default True. + + .. attention:: + This only works if the dash-app was started with :func:`show_dash`. + """ + if self._app is not None: + + old_server = self._app._server_threads.get((self._host, self._port)) + if old_server: + old_server.kill() + old_server.join() + del self._app._server_threads[(self._host, self._port)] + elif warn: + warnings.warn( + "Could not stop the server, either the \n" + + "\t- 'show-dash' method was not called, or \n" + + "\t- the dash-server wasn't started with 'show_dash'" + ) diff --git a/plotly_resampler/figure_resampler.py b/plotly_resampler/figure_resampler/figure_resampler_interface.py similarity index 74% rename from plotly_resampler/figure_resampler.py rename to plotly_resampler/figure_resampler/figure_resampler_interface.py index a508c89b..3446a4b1 100644 --- a/plotly_resampler/figure_resampler.py +++ b/plotly_resampler/figure_resampler/figure_resampler_interface.py @@ -1,11 +1,6 @@ # -*- coding: utf-8 -*- """ -Wrapper around the plotly ``go.Figure`` class which allows bookkeeping and -back-end based resampling of high-frequency sequential data. - -Tip ---- -The term `high-frequency` actually refers very large amounts of sequential data. +Abstract interface of which the concrete *Resampler* classes write towards. """ @@ -14,7 +9,6 @@ __author__ = "Jonas Van Der Donckt, Jeroen Van Der Donckt, Emiel Deprost" import re -import warnings from copy import copy from typing import Dict, Iterable, List, Optional, Tuple, Union from uuid import uuid4 @@ -23,13 +17,10 @@ import numpy as np import pandas as pd import plotly.graph_objects as go -from jupyter_dash import JupyterDash -from dash import Dash from plotly.basedatatypes import BaseTraceType, BaseFigure -from trace_updater import TraceUpdater -from .aggregation import AbstractSeriesAggregator, EfficientLTTB -from .utils import round_td_str, round_number_str +from ..aggregation import AbstractSeriesAggregator, EfficientLTTB +from ..utils import round_td_str, round_number_str from abc import ABC @@ -950,316 +941,3 @@ def _re_matches(regex: re.Pattern, strings: Iterable[str]) -> List[str]: if m is not None: matches.append(m.string) return sorted(matches) - - -class _FigureWidgetResamplerM(type(AbstractFigureAggregator), type(go.FigureWidget)): - # MetaClass for the FigureWidgetResampler - pass - - -class FigureWidgetResampler( - AbstractFigureAggregator, go.FigureWidget, metaclass=_FigureWidgetResamplerM -): - """Data aggregation functionality wrapper for ``go.FigureWidgets``.""" - - def __init__( - self, - figure: go.FigureWidget, - convert_existing_traces: bool = True, - default_n_shown_samples: int = 1000, - default_downsampler: AbstractSeriesAggregator = EfficientLTTB(), - resampled_trace_prefix_suffix: Tuple[str, str] = ( - '[R] ', - "", - ), - show_mean_aggregation_size: bool = True, - verbose: bool = False, - ): - if not isinstance(figure, go.FigureWidget): - figure = go.FigureWidget(figure) - - super().__init__( - figure, - convert_existing_traces, - default_n_shown_samples, - default_downsampler, - resampled_trace_prefix_suffix, - show_mean_aggregation_size, - verbose, - ) - - self._prev_layout = None # Contains the previous xaxis layout configuration - - # used for logging purposes to save a history of layout changes - self._relayout_hist = [] - - # A list of al xaxis string names e.g., "xaxis", "xaxis2", "xaxis3", .... - self._xaxis_list = self._re_matches(re.compile("xaxis\d*"), self._layout.keys()) - # edge case: an empty `go.Figure()` does not yet contain xaxis keys - if not len(self._xaxis_list): - self._xaxis_list = ['xaxis'] - - # Assign the the update-methods to the corresponding classes - showspike_keys = [f"{xaxis}.showspikes" for xaxis in self._xaxis_list] - self.layout.on_change(self._update_spike_ranges, *showspike_keys) - - x_relayout_keys = [f"{xaxis}.range" for xaxis in self._xaxis_list] - self.layout.on_change(self._update_x_ranges, *x_relayout_keys) - - def _update_x_ranges(self, layout, *x_ranges): - """Update the the go.Figure data based on changed x-ranges. - - Parameters - ---------- - layout : go.Layout - The figure's (i.e, self) layout object. Remark that this is a reference, - so if we change self.layout (same object reference), this object will change. - *x_ranges: iterable - A iterable list of current x-ranges, where each x-range is a tuple of two - items, indicating the current/new (if changed) left-right x-range, - respectively. - """ - relayout_dict = {} # variable in which we aim to reconstruct the relayout - # serialize the layout in a new dict object - layout = { - xaxis_str: layout[xaxis_str].to_plotly_json() - for xaxis_str in self._xaxis_list - } - if self._prev_layout is None: - self._prev_layout = layout - - for xaxis_str, x_range in zip(self._xaxis_list, x_ranges): - # We also check whether "range" is within the xaxis its layout otherwise - # It is most-likely an autorange check - if ( - "range" in layout[xaxis_str] - and self._prev_layout[xaxis_str].get("range", []) != x_range - ): - # a change took place -> add to the relayout dict - relayout_dict[f"{xaxis_str}.range[0]"] = x_range[0] - relayout_dict[f"{xaxis_str}.range[1]"] = x_range[1] - - # An update will take place for that trace - # -> save current xaxis range to _prev_layout - self._prev_layout[xaxis_str]['range'] = x_range - - if len(relayout_dict): - # Construct the update data - update_data = self.construct_update_data(relayout_dict) - - if self._print_verbose: - self._relayout_hist.append(dict(zip(self._xaxis_list, x_ranges))) - self._relayout_hist.append(layout) - self._relayout_hist.append(["xaxis-range-update", len(update_data) - 1]) - self._relayout_hist.append("-" * 30) - - with self.batch_update(): - # First update the layout (first item of update_data) - self.layout.update(update_data[0]) - - for xaxis_str in self._xaxis_list: - if 'showspikes' in layout[xaxis_str]: - self.layout[xaxis_str].pop("showspikes") - - # Then update the data - for updated_trace in update_data[1:]: - trace_idx = updated_trace.pop("index") - self.data[trace_idx].update(updated_trace) - - def _update_spike_ranges(self, layout, *showspikes): - """Update the go.Figure based on the changed spike-ranges. - - Parameters - ---------- - layout : go.Layout - The figure's (i.e, self) layout object. Remark that this is a reference, - so if we change self.layout (same object reference), this object will change. - *showspikes: iterable - A iterable where each item is a bool, indicating whether showspikes is set - to true/false for the corresponding xaxis in ``self._xaxis_list``. - """ - relayout_dict = {} # variable in which we aim to reconstruct the relayout - # serialize the layout in a new dict object - layout = { - xaxis_str: layout[xaxis_str].to_plotly_json() - for xaxis_str in self._xaxis_list - } - - if self._prev_layout is None: - return - - for xaxis_str, showspike in zip(self._xaxis_list, showspikes): - if ( - # autorange key must be set to True - layout[xaxis_str].get("autorange", False) - # we only perform updates for traces which have 'range' property, - # as we do need to reconstruct the update-data for these traces - and self._prev_layout[xaxis_str].get("range", None) is not None - ): - relayout_dict[f"{xaxis_str}.autorange"] = True - relayout_dict[f"{xaxis_str}.showspikes"] = showspike - # autorange -> we pop the xaxis range - if 'range' in layout[xaxis_str]: - del layout[xaxis_str]['range'] - - if len(relayout_dict): - # An update will take place, save current layout to _prev_layout - self._prev_layout = layout - - # Construct the update data - update_data = self.construct_update_data(relayout_dict) - if self._print_verbose: - self._relayout_hist.append(layout) - self._relayout_hist.append(["showspikes-update", len(update_data) - 1]) - self._relayout_hist.append("-" * 30) - - with self.batch_update(): - # First update the layout (first item of update_data) - self.layout.update(update_data[0]) - - # Also: Remove the showspikes from the layout, otherwise the autorange - # will not work as intended (it will not be triggered again) - # Note: this removal causes a second trigger of this method - # which will go in the "else" part below. - for xaxis_str in self._xaxis_list: - self.layout[xaxis_str].pop("showspikes") - - # Then, update the data - for updated_trace in update_data[1:]: - trace_idx = updated_trace.pop("index") - self.data[trace_idx].update(updated_trace) - elif self._print_verbose: - self._relayout_hist.append(["showspikes", "initial call or showspikes"]) - self._relayout_hist.append("-" * 40) - - # def show(self, *args, **kwargs): - # super(go.FigureWidget, self).show(*args, **kwargs) - - -class FigureResampler(AbstractFigureAggregator, go.Figure): - """Data aggregation functionality for Figures.""" - - def __init__( - self, - figure: go.Figure = go.Figure(), - convert_existing_traces: bool = True, - default_n_shown_samples: int = 1000, - default_downsampler: AbstractSeriesAggregator = EfficientLTTB(), - resampled_trace_prefix_suffix: Tuple[str, str] = ( - '[R] ', - "", - ), - show_mean_aggregation_size: bool = True, - verbose: bool = False, - ): - assert isinstance(figure, go.Figure) - super().__init__( - figure, - convert_existing_traces, - default_n_shown_samples, - default_downsampler, - resampled_trace_prefix_suffix, - show_mean_aggregation_size, - verbose, - ) - - # The figureAggregator needs a dash app - self._app: JupyterDash | Dash | None = None - self._port: int | None = None - self._host: str | None = None - - def show_dash( - self, - mode=None, - config: dict | None = None, - graph_properties: dict | None = None, - **kwargs, - ): - """Registers the :func:`update_graph` callback & show the figure in a dash app. - - Parameters - ---------- - mode: str, optional - Display mode. One of:\n - * ``"external"``: The URL of the app will be displayed in the notebook - output cell. Clicking this URL will open the app in the default - web browser. - * ``"inline"``: The app will be displayed inline in the notebook output - cell in an iframe. - * ``"jupyterlab"``: The app will be displayed in a dedicated tab in the - JupyterLab interface. Requires JupyterLab and the ``jupyterlab-dash`` - extension. - By default None, which will result in the same behavior as ``"external"``. - config: dict, optional - The configuration options for displaying this figure, by default None. - This ``config`` parameter is the same as the dict that you would pass as - ``config`` argument to the `show` method. - See more https://plotly.com/python/configuration-options/ - graph_properties: dict, optional - Dictionary of (keyword, value) for the properties that should be passed to - the dcc.Graph, by default None. - e.g.: {"style": {"width": "50%"}} - Note: "config" is not allowed as key in this dict, as there is a distinct - ``config`` parameter for this property in this method. - See more https://dash.plotly.com/dash-core-components/graph - **kwargs: dict - Additional app.run_server() kwargs. - e.g.: port - - """ - graph_properties = {} if graph_properties is None else graph_properties - assert "config" not in graph_properties.keys() # There is a param for config - # 1. Construct the Dash app layout - app = JupyterDash("local_app") # TODO -> was jupyterdash - app.layout = dash.html.Div( - [ - dash.dcc.Graph( - id="resample-figure", figure=self, config=config, **graph_properties - ), - TraceUpdater( - id="trace-updater", gdID="resample-figure", sequentialUpdate=False - ), - ] - ) - self.register_update_graph_callback(app, "resample-figure", "trace-updater") - - # 2. Run the app - if ( - self.layout.height is not None - and mode == "inline" - and "height" not in kwargs - ): - # If figure height is specified -> re-use is for inline dash app height - kwargs["height"] = self.layout.height + 18 - - # store the app information, so it can be killed - self._app = app - self._host = kwargs.get("host", "127.0.0.1") - self._port = kwargs.get("port", "8050") - - app.run_server(mode=mode, **kwargs) - - def stop_server(self, warn: bool = True): - """Stop the running dash-app. - - Parameters - ---------- - warn: bool - Whether a warning message will be shown or not, by default True. - - .. attention:: - This only works if the dash-app was started with :func:`show_dash`. - """ - if self._app is not None: - - old_server = self._app._server_threads.get((self._host, self._port)) - if old_server: - old_server.kill() - old_server.join() - del self._app._server_threads[(self._host, self._port)] - elif warn: - warnings.warn( - "Could not stop the server, either the \n" - + "\t- 'show-dash' method was not called, or \n" - + "\t- the dash-server wasn't started with 'show_dash'" - ) diff --git a/plotly_resampler/figure_resampler/figurewidget_resampler.py b/plotly_resampler/figure_resampler/figurewidget_resampler.py new file mode 100644 index 00000000..e84af26e --- /dev/null +++ b/plotly_resampler/figure_resampler/figurewidget_resampler.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +""" +``FigureWidgetResampler`` wrapper around the plotly ``go.FigureWidget`` class. + +""" + +from __future__ import annotations + +__author__ = "Jonas Van Der Donckt, Jeroen Van Der Donckt, Emiel Deprost" + +import re +from typing import Tuple + +import plotly.graph_objects as go + +from .figure_resampler import AbstractFigureAggregator +from ..aggregation import AbstractSeriesAggregator, EfficientLTTB + + +class _FigureWidgetResamplerM(type(AbstractFigureAggregator), type(go.FigureWidget)): + # MetaClass for the FigureWidgetResampler + pass + + +class FigureWidgetResampler( + AbstractFigureAggregator, go.FigureWidget, metaclass=_FigureWidgetResamplerM +): + """Data aggregation functionality wrapper for ``go.FigureWidgets``.""" + + def __init__( + self, + figure: go.FigureWidget, + convert_existing_traces: bool = True, + default_n_shown_samples: int = 1000, + default_downsampler: AbstractSeriesAggregator = EfficientLTTB(), + resampled_trace_prefix_suffix: Tuple[str, str] = ( + '[R] ', + "", + ), + show_mean_aggregation_size: bool = True, + verbose: bool = False, + ): + if not isinstance(figure, go.FigureWidget): + figure = go.FigureWidget(figure) + + super().__init__( + figure, + convert_existing_traces, + default_n_shown_samples, + default_downsampler, + resampled_trace_prefix_suffix, + show_mean_aggregation_size, + verbose, + ) + + self._prev_layout = None # Contains the previous xaxis layout configuration + + # used for logging purposes to save a history of layout changes + self._relayout_hist = [] + + # A list of al xaxis string names e.g., "xaxis", "xaxis2", "xaxis3", .... + self._xaxis_list = self._re_matches(re.compile("xaxis\d*"), self._layout.keys()) + # edge case: an empty `go.Figure()` does not yet contain xaxis keys + if not len(self._xaxis_list): + self._xaxis_list = ['xaxis'] + + # Assign the the update-methods to the corresponding classes + showspike_keys = [f"{xaxis}.showspikes" for xaxis in self._xaxis_list] + self.layout.on_change(self._update_spike_ranges, *showspike_keys) + + x_relayout_keys = [f"{xaxis}.range" for xaxis in self._xaxis_list] + self.layout.on_change(self._update_x_ranges, *x_relayout_keys) + + def _update_x_ranges(self, layout, *x_ranges): + """Update the the go.Figure data based on changed x-ranges. + + Parameters + ---------- + layout : go.Layout + The figure's (i.e, self) layout object. Remark that this is a reference, + so if we change self.layout (same object reference), this object will + change. + *x_ranges: iterable + A iterable list of current x-ranges, where each x-range is a tuple of two + items, indicating the current/new (if changed) left-right x-range, + respectively. + """ + relayout_dict = {} # variable in which we aim to reconstruct the relayout + # serialize the layout in a new dict object + layout = { + xaxis_str: layout[xaxis_str].to_plotly_json() + for xaxis_str in self._xaxis_list + } + if self._prev_layout is None: + self._prev_layout = layout + + for xaxis_str, x_range in zip(self._xaxis_list, x_ranges): + # We also check whether "range" is within the xaxis its layout otherwise + # It is most-likely an autorange check + if ( + "range" in layout[xaxis_str] + and self._prev_layout[xaxis_str].get("range", []) != x_range + ): + # a change took place -> add to the relayout dict + relayout_dict[f"{xaxis_str}.range[0]"] = x_range[0] + relayout_dict[f"{xaxis_str}.range[1]"] = x_range[1] + + # An update will take place for that trace + # -> save current xaxis range to _prev_layout + self._prev_layout[xaxis_str]['range'] = x_range + + if len(relayout_dict): + # Construct the update data + update_data = self.construct_update_data(relayout_dict) + + if self._print_verbose: + self._relayout_hist.append(dict(zip(self._xaxis_list, x_ranges))) + self._relayout_hist.append(layout) + self._relayout_hist.append(["xaxis-range-update", len(update_data) - 1]) + self._relayout_hist.append("-" * 30) + + with self.batch_update(): + # First update the layout (first item of update_data) + self.layout.update(update_data[0]) + + for xaxis_str in self._xaxis_list: + if 'showspikes' in layout[xaxis_str]: + self.layout[xaxis_str].pop("showspikes") + + # Then update the data + for updated_trace in update_data[1:]: + trace_idx = updated_trace.pop("index") + self.data[trace_idx].update(updated_trace) + + def _update_spike_ranges(self, layout, *showspikes): + """Update the go.Figure based on the changed spike-ranges. + + Parameters + ---------- + layout : go.Layout + The figure's (i.e, self) layout object. Remark that this is a reference, + so if we change self.layout (same object reference), this object will + change. + *showspikes: iterable + A iterable where each item is a bool, indicating whether showspikes is set + to true/false for the corresponding xaxis in ``self._xaxis_list``. + """ + relayout_dict = {} # variable in which we aim to reconstruct the relayout + # serialize the layout in a new dict object + layout = { + xaxis_str: layout[xaxis_str].to_plotly_json() + for xaxis_str in self._xaxis_list + } + + if self._prev_layout is None: + return + + for xaxis_str, showspike in zip(self._xaxis_list, showspikes): + if ( + # autorange key must be set to True + layout[xaxis_str].get("autorange", False) + # we only perform updates for traces which have 'range' property, + # as we do need to reconstruct the update-data for these traces + and self._prev_layout[xaxis_str].get("range", None) is not None + ): + relayout_dict[f"{xaxis_str}.autorange"] = True + relayout_dict[f"{xaxis_str}.showspikes"] = showspike + # autorange -> we pop the xaxis range + if 'range' in layout[xaxis_str]: + del layout[xaxis_str]['range'] + + if len(relayout_dict): + # An update will take place, save current layout to _prev_layout + self._prev_layout = layout + + # Construct the update data + update_data = self.construct_update_data(relayout_dict) + if self._print_verbose: + self._relayout_hist.append(layout) + self._relayout_hist.append(["showspikes-update", len(update_data) - 1]) + self._relayout_hist.append("-" * 30) + + with self.batch_update(): + # First update the layout (first item of update_data) + self.layout.update(update_data[0]) + + # Also: Remove the showspikes from the layout, otherwise the autorange + # will not work as intended (it will not be triggered again) + # Note: this removal causes a second trigger of this method + # which will go in the "else" part below. + for xaxis_str in self._xaxis_list: + self.layout[xaxis_str].pop("showspikes") + + # Then, update the data + for updated_trace in update_data[1:]: + trace_idx = updated_trace.pop("index") + self.data[trace_idx].update(updated_trace) + elif self._print_verbose: + self._relayout_hist.append(["showspikes", "initial call or showspikes"]) + self._relayout_hist.append("-" * 40) + + # def show(self, *args, **kwargs): + # super(go.FigureWidget, self).show(*args, **kwargs) From 68671bbd60b3599a5a476e61edf74f4a8928e79a Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Wed, 4 May 2022 17:19:33 +0200 Subject: [PATCH 12/19] :memo: --- docs/sphinx/aggregation.rst | 26 +++++++++++++------------- docs/sphinx/figure_resampler.rst | 26 +++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/docs/sphinx/aggregation.rst b/docs/sphinx/aggregation.rst index 174f3590..eebc02e7 100644 --- a/docs/sphinx/aggregation.rst +++ b/docs/sphinx/aggregation.rst @@ -1,23 +1,23 @@ ------------ -Aggregation ------------ +---------------------------- +Series-wise data aggregation +---------------------------- +^^^^^^^^^^^^^^^^^^^^^ +Aggregation interface +^^^^^^^^^^^^^^^^^^^^^ -^^^^^^^^^^^^^^^^^^ -Aggregator classes -^^^^^^^^^^^^^^^^^^ - -.. automodule:: plotly_resampler.aggregation.aggregators +.. automodule:: plotly_resampler.aggregation.aggregation_interface :members: :undoc-members: :show-inheritance: +---------- -^^^^^^^^^^^^^^^^^^^^^ -Aggregation interface -^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^ +Aggregator classes +^^^^^^^^^^^^^^^^^^ -.. automodule:: plotly_resampler.aggregation.aggregation_interface +.. automodule:: plotly_resampler.aggregation.aggregators :members: :undoc-members: - :show-inheritance: \ No newline at end of file + :show-inheritance: diff --git a/docs/sphinx/figure_resampler.rst b/docs/sphinx/figure_resampler.rst index 0cbda28f..489da970 100644 --- a/docs/sphinx/figure_resampler.rst +++ b/docs/sphinx/figure_resampler.rst @@ -1,8 +1,28 @@ ----------------- +^^^^^^^^^^^^^^^^^^^^^^^^ +AbstractFigureAggregator +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: plotly_resampler.figure_resampler.figure_resampler_interface.AbstractFigureAggregator + :members: + :undoc-members: + :show-inheritance: + +^^^^^^^^^^^^^^^ FigureResampler ----------------- +^^^^^^^^^^^^^^^ + + +.. autoclass:: plotly_resampler.figure_resampler.FigureResampler + :members: + :undoc-members: + :show-inheritance: + + +^^^^^^^^^^^^^^^^^^^^^ +FigureWidgetResampler +^^^^^^^^^^^^^^^^^^^^^ -.. automodule:: plotly_resampler.figure_resampler +.. autoclass:: plotly_resampler.figure_resampler.FigureWidgetResampler :members: :undoc-members: :show-inheritance: From 016c6f1be9c2d3718400ff42cdde8df1608ce4dd Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Thu, 5 May 2022 14:50:43 +0200 Subject: [PATCH 13/19] :memo: --- README.md | 41 +++++++++++++++++------- docs/sphinx/getting_started.rst | 56 +++++++++++++++++++++------------ docs/sphinx/index.rst | 6 ++++ 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 9200ca56..fbded9db 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ > `plotly_resampler`: visualize large sequential data by **adding resampling functionality to Plotly figures** -[Plotly](https://github.com/plotly/plotly.py) is an awesome interactive visualization library, however it can get pretty slow when a lot of data points are visualized (100 000+ datapoints). This library solves this by downsampling the data respective to the view and then plotting the downsampled points. When you interact with the plot (panning, zooming, ...), [dash](https://github.com/plotly/dash) callbacks are used to resample and redraw the figures. +[Plotly](https://github.com/plotly/plotly.py) is an awesome interactive visualization library, however it can get pretty slow when a lot of data points are visualized (100 000+ datapoints). This library solves this by downsampling (aggregating) the data respective to the view and then plotting the aggregated points. When you interact with the plot (panning, zooming, ...), callbacks are used to aggregate and update the figure.

@@ -27,6 +27,10 @@ In [this Plotly-Resampler demo](https://github.com/predict-idlab/plotly-resampler/blob/main/examples/basic_example.ipynb) over `110,000,000` data points are visualized! + + + + @@ -41,12 +45,16 @@ In [this Plotly-Resampler demo](https://github.com/predict-idlab/plotly-resample ## Usage -To **add dynamic resampling to your plotly Figure**, you should; -1. wrap the plotly Figure with `FigureResampler` -2. call `.show_dash()` on the Figure +To **add dynamic resampling** to your plotly Figure +* using a web application with *Dash* callbacks, you should; + 1. wrap the plotly Figure with `FigureResampler` + 2. call `.show_dash()` on the Figure +* within a *jupyter* environment and *without creating a web application*, you should: + 1. wrap the plotly Figure with `FigureWidgetResampler` + 2. output the `FigureWidgetResampler` instance in a cell > **Note**: -> Any plotly Figure can be wrapped with FigureResampler! šŸŽ‰ +> Any plotly Figure can be wrapped with FigureResampler and FigureWidgetResampler! šŸŽ‰ > But, (obviously) only the scatter traces will be resampled. > **Tip** šŸ’”: @@ -56,17 +64,28 @@ To **add dynamic resampling to your plotly Figure**, you should; ```python import plotly.graph_objects as go; import numpy as np -from plotly_resampler import FigureResampler +from plotly_resampler import FigureResampler, FigureWidgetResampler x = np.arange(1_000_000) noisy_sin = (3 + np.sin(x / 200) + np.random.randn(len(x)) / 10) * x / 1_000 +# FigureResampler: dynamic aggregation via a Dash web-app fig = FigureResampler(go.Figure()) fig.add_trace(go.Scattergl(name='noisy sine', showlegend=True), hf_x=x, hf_y=noisy_sin) fig.show_dash(mode='inline') ``` +#### FigureWidgetResampler: dynamic aggregation via `FigureWidget.layout.on_change` +```python +... +# FigureWidgetResampler: dynamic aggregation via `FigureWidget.layout.on_change` +fig = FigureWidgetResampler(go.Figure()) +fig.add_trace(go.Scattergl(name='noisy sine', showlegend=True), hf_x=x, hf_y=noisy_sin) + +fig +``` + ### Features * **Convenient** to use: @@ -74,16 +93,16 @@ fig.show_dash(mode='inline') * allows all other plotly figure construction flexibility to be used! * **Environment-independent** * can be used in Jupyter, vscode-notebooks, Pycharm-notebooks, Google Colab, and even as application (on a server) -* Interface for **various downsampling algorithms**: - * ability to define your preferred sequence aggregation method +* Interface for **various aggregation algorithms**: + * ability to develop or select your preferred sequence aggregation method ### Important considerations & tips -* When running the code on a server, you should forward the port of the `FigureResampler.show_dash()` method to your local machine. +* When running the code on a server, you should forward the port of the `FigureResampler.show_dash()` method to your local machine.
+ **note** that you can add dynamic aggregation to plotly figures with the `FigureWidgetResampler` wrapper. * In general, when using downsampling one should be aware of (possible) [aliasing](https://en.wikipedia.org/wiki/Aliasing) effects. - The
[R] in the legend indicates when the corresponding trace is being resampled (and thus possibly distorted) or not. - + The [R] in the legend indicates when the corresponding trace is being resampled (and thus possibly distorted) or not. Additionally, the `~` suffix represent the mean aggregation bin size in terms of the sequence index. ## Future work šŸ”Ø * Support `.add_traces()` (currently only `.add_trace` is supported) diff --git a/docs/sphinx/getting_started.rst b/docs/sphinx/getting_started.rst index 40176e88..5d653f68 100644 --- a/docs/sphinx/getting_started.rst +++ b/docs/sphinx/getting_started.rst @@ -4,33 +4,45 @@ Getting started šŸš€ ================== +``plotly-resampler`` serves two main **modules**: -``plotly-resampler`` maintains its interactiveness on large data by applying front-end -**resampling**. - - -Users can interact with 2 components: - -* :ref:`FigureResampler `: a wrapper for *plotly.graph\_objects* that serves the adaptive resampling functionality. -* :ref:`aggregation `: this module withholds various data aggregation methods. +* :py:mod:`figure_resampler `: a wrapper for *plotly.graph\_objects Figures*, coupling the dynamic resampling functionality to the *Figure*. +* :py:mod:`aggregation `: a module withholds various data aggregation methods. Installation āš™ļø --------------- -Install via :raw-html:`pip`: +Install via `pip `_: .. code:: bash pip install plotly-resampler - How to use šŸ“ˆ ------------- -To **add dynamic resampling to a plotly Figure**, you should; +Dynamic resampling callbacks are realized with either: + +* `Dash `_ callbacks, when a ``go.Figure`` object is wrapped with dynamic aggregation functionality. + + .. note:: + + This is especially useful when working with **dash functionality** or when you do **not want to solely operate in jupyter environments**. + + To **add dynamic resampling**, you should: + 1. wrap the plotly Figure with :class:`FigureResampler ` + 2. call :func:`.show_dash() ` on the Figure - 1. wrap the plotly Figure with :class:`FigureResampler ` - 2. call :func:`.show_dash() ` on the Figure +* `FigureWidget.layout.on_change `_ , when a ``go.FigureWidget`` is used within a ``.ipynb`` environment. + + .. note:: + + This is especially useful when developing in ``jupyter`` environments and when **you cannot open/forward a network-port**. + + + To **add dynamic resampling** using a **FigureWidget**, you should: + 1. wrap your plotly Figure (can be a ``go.Figure``) with :class:`FigureWidgetResampler ` + 2. Create a cell output for the ``FigureWidgetResampler`` instance .. tip:: @@ -38,11 +50,11 @@ To **add dynamic resampling to a plotly Figure**, you should; .. note:: - Any plotly Figure can be wrapped with :class:`FigureResampler `! šŸŽ‰ :raw-html:`
` - But, (obviously) only the scatter traces will be resampled. + Any plotly Figure can be wrapped with dynamic aggregation functionality! šŸŽ‰ :raw-html:`
` + But, (obviously) only the scatter traces will be resampled. -Working example āœ… ------------------- +Working examples āœ… +------------------- .. code:: py @@ -57,6 +69,10 @@ Working example āœ… fig.show_dash(mode='inline') +The gif below demonstrates the example usage of of :class:`FigureWidgetResampler `, where ``JupyterLab`` is used as environment and the ``FigureWidgetResampler`` instance it's output is redirected into a new view. Also note how you are able to dynamically add traces! + +.. image:: https://raw.githubusercontent.com/predict-idlab/plotly-resampler/main/docs/sphinx/_static/figurewidget.gif + Important considerations & tips šŸšØ ---------------------------------- @@ -99,7 +115,7 @@ Plotly-resampler & not high-frequency traces šŸ” ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. Tip:: - + In the *Skin conductance example* of the :raw-html:`
basic_example.ipynb`, we deal with such low-frequency traces. The :func:`add_trace ` method allows configuring argument which allows us to deal with low-frequency traces. @@ -108,11 +124,11 @@ The :func:`add_trace
+ +As shown in the demo above, ``plotly-resampler`` maintains its interactiveness on large data by applying front-end **resampling respective to the view**. + .. raw:: html From 2f7c8ce31c7826f5402447735b45ba885713ac86 Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Thu, 5 May 2022 14:51:03 +0200 Subject: [PATCH 14/19] :book: --- plotly_resampler/figure_resampler/__init__.py | 5 +++-- plotly_resampler/figure_resampler/figure_resampler.py | 5 +++-- .../figure_resampler/figure_resampler_interface.py | 6 +++++- .../figure_resampler/figurewidget_resampler.py | 9 +++++++-- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/plotly_resampler/figure_resampler/__init__.py b/plotly_resampler/figure_resampler/__init__.py index a9cdc75f..f6ecd9a7 100644 --- a/plotly_resampler/figure_resampler/__init__.py +++ b/plotly_resampler/figure_resampler/__init__.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- """ -Module withholdingg wrappers for the plotly ``go.Figure`` and ``go.FigureWidget`` class -which allows bookkeeping and back-end based resampling of high-frequency sequential data. +Module withholding wrappers for the plotly ``go.Figure`` and ``go.FigureWidget`` class +which allows bookkeeping and back-end based resampling of high-frequency sequential +data. Tip --- diff --git a/plotly_resampler/figure_resampler/figure_resampler.py b/plotly_resampler/figure_resampler/figure_resampler.py index 72861ee9..b67bc015 100644 --- a/plotly_resampler/figure_resampler/figure_resampler.py +++ b/plotly_resampler/figure_resampler/figure_resampler.py @@ -2,6 +2,8 @@ """ ``FigureResampler`` wrapper around the plotly ``go.Figure`` class. +Creates a web-application and uses ``dash`` callbacks to enable dynamic resampling. + """ from __future__ import annotations @@ -88,8 +90,7 @@ def show_dash( ``config`` parameter for this property in this method. See more https://dash.plotly.com/dash-core-components/graph **kwargs: dict - Additional app.run_server() kwargs. - e.g.: port + Additional app.run_server() kwargs. e.g.: port """ graph_properties = {} if graph_properties is None else graph_properties diff --git a/plotly_resampler/figure_resampler/figure_resampler_interface.py b/plotly_resampler/figure_resampler/figure_resampler_interface.py index 3446a4b1..0ba70bc2 100644 --- a/plotly_resampler/figure_resampler/figure_resampler_interface.py +++ b/plotly_resampler/figure_resampler/figure_resampler_interface.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- """ -Abstract interface of which the concrete *Resampler* classes write towards. +Abstract ``AbstractFigureAggregator`` interface for the concrete *Resampler* classes. + +.. |br| raw:: html + +
""" diff --git a/plotly_resampler/figure_resampler/figurewidget_resampler.py b/plotly_resampler/figure_resampler/figurewidget_resampler.py index e84af26e..c5da4f1d 100644 --- a/plotly_resampler/figure_resampler/figurewidget_resampler.py +++ b/plotly_resampler/figure_resampler/figurewidget_resampler.py @@ -2,6 +2,8 @@ """ ``FigureWidgetResampler`` wrapper around the plotly ``go.FigureWidget`` class. +Utilizes the ``fig.layout.on_change`` method to enable dynamic resampling. + """ from __future__ import annotations @@ -25,7 +27,10 @@ class _FigureWidgetResamplerM(type(AbstractFigureAggregator), type(go.FigureWidg class FigureWidgetResampler( AbstractFigureAggregator, go.FigureWidget, metaclass=_FigureWidgetResamplerM ): - """Data aggregation functionality wrapper for ``go.FigureWidgets``.""" + """Data aggregation functionality wrapper for ``go.FigureWidgets``. + + .. attention:: This wrapper only works within ``jupyter``-based environments. + """ def __init__( self, @@ -105,7 +110,7 @@ def _update_x_ranges(self, layout, *x_ranges): relayout_dict[f"{xaxis_str}.range[0]"] = x_range[0] relayout_dict[f"{xaxis_str}.range[1]"] = x_range[1] - # An update will take place for that trace + # An update will take place for that trace # -> save current xaxis range to _prev_layout self._prev_layout[xaxis_str]['range'] = x_range From 68ece6ee04fb90a61ff53b85ec8af56c001822dd Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Thu, 5 May 2022 14:51:59 +0200 Subject: [PATCH 15/19] :newspaper: --- plotly_resampler/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plotly_resampler/__init__.py b/plotly_resampler/__init__.py index b3f292fb..44e8182f 100644 --- a/plotly_resampler/__init__.py +++ b/plotly_resampler/__init__.py @@ -1,6 +1,4 @@ -"""**plotly\_resampler**: visualizing large sequences - -""" +"""**plotly\_resampler**: visualizing large sequences.""" from .aggregation import ( LTTB, From 875abab1a766afcb9f7c41fec66649cfc9369a5a Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Thu, 5 May 2022 15:07:32 +0200 Subject: [PATCH 16/19] :pencil: add `.show` documentation --- .../figure_resampler/figurewidget_resampler.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plotly_resampler/figure_resampler/figurewidget_resampler.py b/plotly_resampler/figure_resampler/figurewidget_resampler.py index c5da4f1d..48ecc60d 100644 --- a/plotly_resampler/figure_resampler/figurewidget_resampler.py +++ b/plotly_resampler/figure_resampler/figurewidget_resampler.py @@ -29,7 +29,13 @@ class FigureWidgetResampler( ): """Data aggregation functionality wrapper for ``go.FigureWidgets``. - .. attention:: This wrapper only works within ``jupyter``-based environments. + .. attention:: + + * This wrapper only works within ``jupyter``-based environments. + * The ``.show()`` method returns a **static figure** on which the + **dynamic resampling cannot be performed**. To allow dynamic resampling, + you should just output the ``FigureWidgetResampler`` object in a cell. + """ def __init__( From 525e0290757e745b2c6791483573d2e63fc62e4d Mon Sep 17 00:00:00 2001 From: jvdd Date: Fri, 6 May 2022 08:58:23 +0200 Subject: [PATCH 17/19] :pen: review code --- README.md | 18 +++++++++++------- docs/sphinx/getting_started.rst | 6 +++--- .../figure_resampler/figure_resampler.py | 4 ++-- .../figure_resampler_interface.py | 2 ++ .../figure_resampler/figurewidget_resampler.py | 2 +- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index fbded9db..1f79b356 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ [![support-version](https://img.shields.io/pypi/pyversions/plotly-resampler)](https://img.shields.io/pypi/pyversions/plotly-resampler) [![codecov](https://img.shields.io/codecov/c/github/predict-idlab/plotly-resampler?logo=codecov)](https://codecov.io/gh/predict-idlab/plotly-resampler) [![Code quality](https://img.shields.io/lgtm/grade/python/github/predict-idlab/plotly-resampler?label=code%20quality&logo=lgtm)](https://lgtm.com/projects/g/predict-idlab/plotly-resampler/context:python) +[![Downloads](https://pepy.tech/badge/plotly-resampler)](https://pepy.tech/project/plotly-resampler) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?)](http://makeapullrequest.com) [![Documentation](https://github.com/predict-idlab/plotly-resampler/actions/workflows/deploy-docs.yml/badge.svg)](https://github.com/predict-idlab/plotly-resampler/actions/workflows/deploy-docs.yml) [![Testing](https://github.com/predict-idlab/plotly-resampler/actions/workflows/test.yml/badge.svg)](https://github.com/predict-idlab/plotly-resampler/actions/workflows/test.yml) @@ -17,7 +18,7 @@ > `plotly_resampler`: visualize large sequential data by **adding resampling functionality to Plotly figures** -[Plotly](https://github.com/plotly/plotly.py) is an awesome interactive visualization library, however it can get pretty slow when a lot of data points are visualized (100 000+ datapoints). This library solves this by downsampling (aggregating) the data respective to the view and then plotting the aggregated points. When you interact with the plot (panning, zooming, ...), callbacks are used to aggregate and update the figure. +[Plotly](https://github.com/plotly/plotly.py) is an awesome interactive visualization library, however it can get pretty slow when a lot of data points are visualized (100 000+ datapoints). This library solves this by downsampling (aggregating) the data respective to the view and then plotting the aggregated points. When you interact with the plot (panning, zooming, ...), callbacks are used to aggregate data and update the figure.

@@ -54,11 +55,11 @@ To **add dynamic resampling** to your plotly Figure 2. output the `FigureWidgetResampler` instance in a cell > **Note**: -> Any plotly Figure can be wrapped with FigureResampler and FigureWidgetResampler! šŸŽ‰ +> Any plotly Figure can be wrapped with `FigureResampler` and `FigureWidgetResampler`! šŸŽ‰ > But, (obviously) only the scatter traces will be resampled. > **Tip** šŸ’”: -> For significant faster initial loading of the Figure, we advise to wrap the constructor of the plotly Figure with `FigureResampler` and add the trace data as `hf_x` and `hf_y` +> For significant faster initial loading of the Figure, we advise to wrap the constructor of the plotly Figure and add the trace data as `hf_x` and `hf_y` ### Minimal example @@ -69,7 +70,7 @@ from plotly_resampler import FigureResampler, FigureWidgetResampler x = np.arange(1_000_000) noisy_sin = (3 + np.sin(x / 200) + np.random.randn(len(x)) / 10) * x / 1_000 -# FigureResampler: dynamic aggregation via a Dash web-app +# OPTION 1 - FigureResampler: dynamic aggregation via a Dash web-app fig = FigureResampler(go.Figure()) fig.add_trace(go.Scattergl(name='noisy sine', showlegend=True), hf_x=x, hf_y=noisy_sin) @@ -79,7 +80,7 @@ fig.show_dash(mode='inline') #### FigureWidgetResampler: dynamic aggregation via `FigureWidget.layout.on_change` ```python ... -# FigureWidgetResampler: dynamic aggregation via `FigureWidget.layout.on_change` +# OPTION 2 - FigureWidgetResampler: dynamic aggregation via `FigureWidget.layout.on_change` fig = FigureWidgetResampler(go.Figure()) fig.add_trace(go.Scattergl(name='noisy sine', showlegend=True), hf_x=x, hf_y=noisy_sin) @@ -89,7 +90,9 @@ fig ### Features * **Convenient** to use: - * just add the `FigureResampler` decorator around a plotly Figure and call `.show_dash()` + * just add either + * `FigureResampler` decorator around a plotly Figure and call `.show_dash()` + * `FigureWidgetResampler` decorator around a plotly Figure and output the instance in a cell * allows all other plotly figure construction flexibility to be used! * **Environment-independent** * can be used in Jupyter, vscode-notebooks, Pycharm-notebooks, Google Colab, and even as application (on a server) @@ -100,9 +103,10 @@ fig ### Important considerations & tips * When running the code on a server, you should forward the port of the `FigureResampler.show_dash()` method to your local machine.
- **note** that you can add dynamic aggregation to plotly figures with the `FigureWidgetResampler` wrapper. + **Note** that you can add dynamic aggregation to plotly figures with the `FigureWidgetResampler` wrapper without needing to forward a port! * In general, when using downsampling one should be aware of (possible) [aliasing](https://en.wikipedia.org/wiki/Aliasing) effects. The
[R] in the legend indicates when the corresponding trace is being resampled (and thus possibly distorted) or not. Additionally, the `~` suffix represent the mean aggregation bin size in terms of the sequence index. + ## Future work šŸ”Ø * Support `.add_traces()` (currently only `.add_trace` is supported) diff --git a/docs/sphinx/getting_started.rst b/docs/sphinx/getting_started.rst index 5d653f68..1e970f02 100644 --- a/docs/sphinx/getting_started.rst +++ b/docs/sphinx/getting_started.rst @@ -7,7 +7,7 @@ Getting started šŸš€ ``plotly-resampler`` serves two main **modules**: * :py:mod:`figure_resampler `: a wrapper for *plotly.graph\_objects Figures*, coupling the dynamic resampling functionality to the *Figure*. -* :py:mod:`aggregation `: a module withholds various data aggregation methods. +* :py:mod:`aggregation `: a module that withholds various data aggregation methods. Installation āš™ļø --------------- @@ -42,11 +42,11 @@ Dynamic resampling callbacks are realized with either: To **add dynamic resampling** using a **FigureWidget**, you should: 1. wrap your plotly Figure (can be a ``go.Figure``) with :class:`FigureWidgetResampler ` - 2. Create a cell output for the ``FigureWidgetResampler`` instance + 2. output the ```FigureWidgetResampler`` instance in a cell .. tip:: - For **significant faster initial loading** of the Figure, we advise to wrap the constructor of the plotly Figure with :class:`FigureResampler ` and add the trace data as ``hf_x`` and ``hf_y`` + For **significant faster initial loading** of the Figure, we advise to wrap the constructor of the plotly Figure with either :class:`FigureResampler ` or :class:`FigureWidgetResampler ` and add the trace data as ``hf_x`` and ``hf_y`` .. note:: diff --git a/plotly_resampler/figure_resampler/figure_resampler.py b/plotly_resampler/figure_resampler/figure_resampler.py index b67bc015..f9ae47a0 100644 --- a/plotly_resampler/figure_resampler/figure_resampler.py +++ b/plotly_resampler/figure_resampler/figure_resampler.py @@ -50,7 +50,7 @@ def __init__( verbose, ) - # The figureAggregator needs a dash app + # The FigureResampler needs a dash app self._app: JupyterDash | Dash | None = None self._port: int | None = None self._host: str | None = None @@ -96,7 +96,7 @@ def show_dash( graph_properties = {} if graph_properties is None else graph_properties assert "config" not in graph_properties.keys() # There is a param for config # 1. Construct the Dash app layout - app = JupyterDash("local_app") # TODO -> was jupyterdash + app = JupyterDash("local_app") app.layout = dash.html.Div( [ dash.dcc.Graph( diff --git a/plotly_resampler/figure_resampler/figure_resampler_interface.py b/plotly_resampler/figure_resampler/figure_resampler_interface.py index 0ba70bc2..178bf7cf 100644 --- a/plotly_resampler/figure_resampler/figure_resampler_interface.py +++ b/plotly_resampler/figure_resampler/figure_resampler_interface.py @@ -30,6 +30,8 @@ class AbstractFigureAggregator(BaseFigure, ABC): + """Abstract interface for data aggregation functionality for plotly figures.""" + def __init__( self, figure: BaseFigure, diff --git a/plotly_resampler/figure_resampler/figurewidget_resampler.py b/plotly_resampler/figure_resampler/figurewidget_resampler.py index 48ecc60d..aa4f7caa 100644 --- a/plotly_resampler/figure_resampler/figurewidget_resampler.py +++ b/plotly_resampler/figure_resampler/figurewidget_resampler.py @@ -40,7 +40,7 @@ class FigureWidgetResampler( def __init__( self, - figure: go.FigureWidget, + figure: go.FigureWidget | go.Figure = go.Figure(), convert_existing_traces: bool = True, default_n_shown_samples: int = 1000, default_downsampler: AbstractSeriesAggregator = EfficientLTTB(), From 10cb22a1b79263fe42849acc32434fbec643ff13 Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Fri, 6 May 2022 09:34:27 +0200 Subject: [PATCH 18/19] :pen: --- .../figure_resampler/figure_resampler.py | 25 +++++++++++++++++++ .../figure_resampler_interface.py | 25 ------------------- .../figurewidget_resampler.py | 13 ++++------ tests/test_figure_resampler.py | 2 +- tests/test_figurewidget_resampler.py | 2 +- 5 files changed, 32 insertions(+), 35 deletions(-) diff --git a/plotly_resampler/figure_resampler/figure_resampler.py b/plotly_resampler/figure_resampler/figure_resampler.py index f9ae47a0..96eea658 100644 --- a/plotly_resampler/figure_resampler/figure_resampler.py +++ b/plotly_resampler/figure_resampler/figure_resampler.py @@ -149,3 +149,28 @@ def stop_server(self, warn: bool = True): + "\t- 'show-dash' method was not called, or \n" + "\t- the dash-server wasn't started with 'show_dash'" ) + + def register_update_graph_callback( + self, app: dash.Dash, graph_id: str, trace_updater_id: str + ): + """Register the :func:`construct_update_data` method as callback function to + the passed dash-app. + + Parameters + ---------- + app: Union[dash.Dash, JupyterDash] + The app in which the callback will be registered. + graph_id: + The id of the ``dcc.Graph``-component which withholds the to-be resampled + Figure. + trace_updater_id + The id of the ``TraceUpdater`` component. This component is leveraged by + ``FigureResampler`` to efficiently POST the to-be-updated data to the + front-end. + + """ + app.callback( + dash.dependencies.Output(trace_updater_id, "updateData"), + dash.dependencies.Input(graph_id, "relayoutData"), + prevent_initial_call=True, + )(self.construct_update_data) diff --git a/plotly_resampler/figure_resampler/figure_resampler_interface.py b/plotly_resampler/figure_resampler/figure_resampler_interface.py index 178bf7cf..f409ccb2 100644 --- a/plotly_resampler/figure_resampler/figure_resampler_interface.py +++ b/plotly_resampler/figure_resampler/figure_resampler_interface.py @@ -913,31 +913,6 @@ def construct_update_data(self, relayout_data: dict) -> List[dict]: layout_traces_list.append(trace_reduced) return layout_traces_list - def register_update_graph_callback( - self, app: dash.Dash, graph_id: str, trace_updater_id: str - ): - """Register the :func:`construct_update_data` method as callback function to - the passed dash-app. - - Parameters - ---------- - app: Union[dash.Dash, JupyterDash] - The app in which the callback will be registered. - graph_id: - The id of the ``dcc.Graph``-component which withholds the to-be resampled - Figure. - trace_updater_id - The id of the ``TraceUpdater`` component. This component is leveraged by - ``FigureResampler`` to efficiently POST the to-be-updated data to the - front-end. - - """ - app.callback( - dash.dependencies.Output(trace_updater_id, "updateData"), - dash.dependencies.Input(graph_id, "relayoutData"), - prevent_initial_call=True, - )(self.construct_update_data) - @staticmethod def _re_matches(regex: re.Pattern, strings: Iterable[str]) -> List[str]: """Returns all the items in ``strings`` which regex.match(es) ``regex``.""" diff --git a/plotly_resampler/figure_resampler/figurewidget_resampler.py b/plotly_resampler/figure_resampler/figurewidget_resampler.py index aa4f7caa..f0416ea8 100644 --- a/plotly_resampler/figure_resampler/figurewidget_resampler.py +++ b/plotly_resampler/figure_resampler/figurewidget_resampler.py @@ -73,7 +73,7 @@ def __init__( self._xaxis_list = self._re_matches(re.compile("xaxis\d*"), self._layout.keys()) # edge case: an empty `go.Figure()` does not yet contain xaxis keys if not len(self._xaxis_list): - self._xaxis_list = ['xaxis'] + self._xaxis_list = ["xaxis"] # Assign the the update-methods to the corresponding classes showspike_keys = [f"{xaxis}.showspikes" for xaxis in self._xaxis_list] @@ -118,7 +118,7 @@ def _update_x_ranges(self, layout, *x_ranges): # An update will take place for that trace # -> save current xaxis range to _prev_layout - self._prev_layout[xaxis_str]['range'] = x_range + self._prev_layout[xaxis_str]["range"] = x_range if len(relayout_dict): # Construct the update data @@ -135,7 +135,7 @@ def _update_x_ranges(self, layout, *x_ranges): self.layout.update(update_data[0]) for xaxis_str in self._xaxis_list: - if 'showspikes' in layout[xaxis_str]: + if "showspikes" in layout[xaxis_str]: self.layout[xaxis_str].pop("showspikes") # Then update the data @@ -177,8 +177,8 @@ def _update_spike_ranges(self, layout, *showspikes): relayout_dict[f"{xaxis_str}.autorange"] = True relayout_dict[f"{xaxis_str}.showspikes"] = showspike # autorange -> we pop the xaxis range - if 'range' in layout[xaxis_str]: - del layout[xaxis_str]['range'] + if "range" in layout[xaxis_str]: + del layout[xaxis_str]["range"] if len(relayout_dict): # An update will take place, save current layout to _prev_layout @@ -209,6 +209,3 @@ def _update_spike_ranges(self, layout, *showspikes): elif self._print_verbose: self._relayout_hist.append(["showspikes", "initial call or showspikes"]) self._relayout_hist.append("-" * 40) - - # def show(self, *args, **kwargs): - # super(go.FigureWidget, self).show(*args, **kwargs) diff --git a/tests/test_figure_resampler.py b/tests/test_figure_resampler.py index cd6e0731..35c16800 100644 --- a/tests/test_figure_resampler.py +++ b/tests/test_figure_resampler.py @@ -90,7 +90,7 @@ def test_add_trace_not_resampling(float_series): def test_add_scatter_trace_no_data(): - fig = FigureResampler(go.Figure(), default_n_shown_samples=1000) + fig = FigureResampler(default_n_shown_samples=1000) # no x and y data fig.add_trace(go.Scatter()) diff --git a/tests/test_figurewidget_resampler.py b/tests/test_figurewidget_resampler.py index 6a874e9b..3d9d60e3 100644 --- a/tests/test_figurewidget_resampler.py +++ b/tests/test_figurewidget_resampler.py @@ -90,7 +90,7 @@ def test_add_trace_not_resampling(float_series): def test_add_scatter_trace_no_data(): - fig = FigureWidgetResampler(go.Figure(), default_n_shown_samples=1000) + fig = FigureWidgetResampler(default_n_shown_samples=1000) # no x and y data fig.add_trace(go.Scatter()) From 2ad0752e88a56dda3b8ae83e92b74ca9f904c411 Mon Sep 17 00:00:00 2001 From: jonvdrdo Date: Fri, 6 May 2022 10:03:37 +0200 Subject: [PATCH 19/19] :feet: adding FigureWidgetResampler example --- examples/README.md | 7 +- examples/figurewidget_example.ipynb | 1572 +++++++++++++++++++++++++++ 2 files changed, 1578 insertions(+), 1 deletion(-) create mode 100644 examples/figurewidget_example.ipynb diff --git a/examples/README.md b/examples/README.md index 88bb0f0c..03b0c6eb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,7 +7,12 @@ plotly-resampler in various use cases. The testing CI/CD of plotly resampler uses _selenium_ and _selenium-wire_ to test the interactiveness of various figures. All these figures are shown in -the [basic-example notebook](basic_example.ipynb) +the [basic example notebook](basic_example.ipynb) + +### 0.1 Figurewidget example + +The [figurewidget example notebook](figurewidget_example.ipynb) utilizes the `FigureWidgetResampler` wrapper to +create a `go.FigureWidget` with dynamic aggregation functionality. A major advantage of this approach is that this does not create a web application, thus not needing to be able to create / forward a network port. ## 1. Dash apps diff --git a/examples/figurewidget_example.ipynb b/examples/figurewidget_example.ipynb new file mode 100644 index 00000000..8f9df111 --- /dev/null +++ b/examples/figurewidget_example.ipynb @@ -0,0 +1,1572 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "import plotly.graph_objs as go\n", + "import numpy as np\n", + "from plotly_resampler.figure_resampler import FigureWidgetResampler\n", + "from plotly.subplots import make_subplots" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "n = 1_000_000 # the nbr of data points\n", + "x = np.arange(n)\n", + "y = np.sin(x / 200) + np.random.random(len(x)) / 10" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**note**: \n", + "* to use `FigureWidgetResampler`, you need to have `ipywidgets` installed\n", + "* `FigureWidgetResampler` does not start a web applcication, making this wrapper suitable jupyter based-environemnts, where multiple `FigureWidgets` can be created" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Basic `FigureWidgetResampler` example" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To utilize FigureWidgetResampler, you just need to:\n", + "* wrap your figure with a `FigureWidgetResampler`\n", + "* output this wrapped instance in a cell" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "c478aeb87d6645319e54ba0c3ef2bb27", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "FigureWidgetResampler({\n", + " 'data': [{'name': '[R] trace 0 1000\n", + "\t[i] DOWNSAMPLE None\t1000000->1000\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "ed08193852c147f9b0824e4562b6e28a", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "FigureWidgetResampler({\n", + " 'data': [{'name': '[R] trace 0 [R] 0 ā€¦" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig = FigureWidgetResampler()\n", + "\n", + "# 10 sensors, 500_000 datapoints each on a singe plot\n", + "for i in range(10):\n", + " fig.add_trace(go.Scattergl(name=str(i)), hf_x=x, hf_y=y + i)\n", + "\n", + "fig.update_layout(\n", + " height=600, title=f\"10 traces -> {10*500_000:,} datapoints\", title_x=0.5\n", + ")\n", + "fig" + ] + } + ], + "metadata": { + "interpreter": { + "hash": "dda2a6ba7b442de4a5fc8ab16a06cb056e727f9750f1dde6d7d9fc5541ed6f4e" + }, + "kernelspec": { + "display_name": "plotly-resampler", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "state": { + "1c83d8e47a8e40a9a503293990493f4c": { + "buffers": [ + { + "data": "LDaQTnNCyD9cY9rSRbXFP4vjiWtE3+I/AkRrJ03z1D+KTE3SevvgP5XzMBIiY+U/eA9Hc5Ko7D/Q7haGtXrkPxvZvDZEceo/jViUWl727D8TiGuvDUjrP9mAraZ/LOo/6DbGIdcRxT9PEczs5bjmP+zftNGVU8s/gK8ceo2EsT+khHj1aprlP4Qbe20zCtI/c69o+ypv4z9R9a5bCOjtP72hYBm/aeI/spC+U7kx7z9kXpYD6wDBPxIrr4WtNuw/TCymNI4UzD+gvwRkx+afP1iCmInhRLo/0zas/Jzl7j+SV63KnjvvPyCoNUqCDNo/Mjl2kjZo2T/b4FK5yKfhP1rSKFfuoe4/kmk328+D4j//4sxNbaHqP4BFHmja2eY/sLtjmid02z8Hd7/Cn8PnPw7ggZvEcOs/L85GSzBe7j8LJLPMAFLvP/zaqAhN2uw/S3KdKkSD4z/9CxoGneDsP/COFsChYaQ/JNWLQY4W1T9wl1XkTrCpP3TxSgipiMI/wJogGBu6vz/laWAIzYjgP+Ltj4sWmOs/EnrY/JKq3z/MCnv8M4XPP/b1EZrgUdw//9cMQchf4j/oz9Jh+v+yP896f4igSeM/oGYE0E1o3D+X4R8JD6HsPyTsk6WNDNU/suDPr/292j9V7Y9wxLftP4VdIQ8Aoes/4FovG0L9lT9MrdvzdlrXPxSZmkP8SsI/0ouBQBgZ7T9MoPRiHJTKPyK8FXIhaOc/csjQenDB3z+wa4lZMa6rP1G4O5rU6O0/Zr0h89sX7j8kpcIh88jPP+gFbPis/+A/aCuJOIxwxj8Y6QPTuNjFP9Tar4ek9cY/+LJ/lHPH5T/07JjejLTvP5BKs6dSPKQ/rOugA09Exz9ueggf3VbQP2ytCRJ84tY/XidvfTly5D9YjVbeBFTsP15YFgdRceY/Qcrdw1ql7T9GkPJaTIHjP7BcRI+eReU/8H8oC1r0tz+QbCMVf9TGPxCvfYxK888/fW/gqyLa4D+d/FIl2w7oP6y9viOoMd8/AMyyvKy22D8+FNNZ4fzrP+FufTUk8+U/JtZ9T32B6T8=", + "encoding": "base64", + "path": [ + "_data", + 0, + "y", + "value" + ] + } + ], + "model_module": "plotlywidget", + "model_module_version": "^4.14.3", + "model_name": "FigureModel", + "state": { + "_config": { + "plotlyServerURL": "https://plot.ly" + }, + "_data": [ + { + "type": "scatter", + "uid": "c541f32e-17c8-4aee-91bc-d1d04860572e", + "x": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99 + ], + "y": { + "dtype": "float64", + "shape": [ + 100 + ], + "value": {} + } + } + ], + "_js2py_layoutDelta": { + "layout_delta": { + "activeshape": { + "fillcolor": "rgb(255,0,255)", + "opacity": 0.5 + }, + "annotations": [], + "autosize": true, + "autotypenumbers": "strict", + "calendar": "gregorian", + "clickmode": "event", + "colorscale": { + "diverging": [ + [ + 0, + "#8e0152" + ], + [ + 0.1, + "#c51b7d" + ], + [ + 0.2, + "#de77ae" + ], + [ + 0.3, + "#f1b6da" + ], + [ + 0.4, + "#fde0ef" + ], + [ + 0.5, + "#f7f7f7" + ], + [ + 0.6, + "#e6f5d0" + ], + [ + 0.7, + "#b8e186" + ], + [ + 0.8, + "#7fbc41" + ], + [ + 0.9, + "#4d9221" + ], + [ + 1, + "#276419" + ] + ], + "sequential": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "sequentialminus": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ] + }, + "colorway": [ + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", + "#FF97FF", + "#FECB52" + ], + "dragmode": "zoom", + "font": { + "color": "#2a3f5f", + "family": "\"Open Sans\", verdana, arial, sans-serif", + "size": 12 + }, + "hidesources": false, + "hoverdistance": 20, + "hoverlabel": { + "align": "left", + "font": { + "family": "Arial, sans-serif", + "size": 13 + }, + "namelength": 15 + }, + "hovermode": "closest", + "images": [], + "margin": { + "autoexpand": true + }, + "modebar": { + "activecolor": "rgba(68, 68, 68, 0.7)", + "bgcolor": "rgba(255, 255, 255, 0.5)", + "color": "rgba(68, 68, 68, 0.3)", + "orientation": "h" + }, + "newshape": { + "drawdirection": "diagonal", + "fillcolor": "rgba(0,0,0,0)", + "fillrule": "evenodd", + "layer": "above", + "line": { + "color": "#444", + "dash": "solid", + "width": 4 + }, + "opacity": 1 + }, + "paper_bgcolor": "white", + "plot_bgcolor": "white", + "separators": ".,", + "shapes": [], + "showlegend": false, + "sliders": [], + "spikedistance": 20, + "title": { + "font": { + "color": "#2a3f5f", + "family": "\"Open Sans\", verdana, arial, sans-serif", + "size": 17 + }, + "pad": { + "b": 0, + "l": 0, + "r": 0, + "t": 0 + }, + "text": "Click to enter Plot title", + "x": 0.05, + "xanchor": "auto", + "xref": "container", + "y": "auto", + "yanchor": "auto", + "yref": "container" + }, + "uniformtext": { + "mode": false + }, + "updatemenus": [], + "width": 993.2, + "xaxis": { + "anchor": "y", + "automargin": true, + "autotypenumbers": "strict", + "color": "#444", + "constrain": "range", + "constraintoward": "center", + "domain": [ + 0, + 1 + ], + "dtick": 10, + "exponentformat": "B", + "fixedrange": false, + "gridcolor": "#EBF0F8", + "gridwidth": 1, + "hoverformat": "", + "layer": "above traces", + "minexponent": 3, + "nticks": 0, + "range": [ + 0, + 99 + ], + "rangemode": "normal", + "separatethousands": false, + "showexponent": "all", + "showgrid": true, + "showline": false, + "showticklabels": true, + "side": "bottom", + "tick0": 0, + "tickangle": "auto", + "tickfont": { + "color": "#2a3f5f", + "family": "\"Open Sans\", verdana, arial, sans-serif", + "size": 12 + }, + "tickformat": "", + "ticklabelposition": "outside", + "tickmode": "auto", + "tickprefix": "", + "ticks": "", + "ticksuffix": "", + "title": { + "font": { + "color": "#2a3f5f", + "family": "\"Open Sans\", verdana, arial, sans-serif", + "size": 14 + }, + "standoff": 15, + "text": "Click to enter X axis title" + }, + "type": "linear", + "visible": true, + "zeroline": true, + "zerolinecolor": "#EBF0F8", + "zerolinewidth": 2 + }, + "yaxis": { + "anchor": "x", + "automargin": true, + "autotypenumbers": "strict", + "color": "#444", + "constrain": "range", + "constraintoward": "middle", + "domain": [ + 0, + 1 + ], + "dtick": 0.2, + "exponentformat": "B", + "fixedrange": false, + "gridcolor": "#EBF0F8", + "gridwidth": 1, + "hoverformat": "", + "layer": "above traces", + "minexponent": 3, + "nticks": 0, + "range": [ + -0.03237696803860817, + 1.044640712077876 + ], + "rangemode": "normal", + "separatethousands": false, + "showexponent": "all", + "showgrid": true, + "showline": false, + "showticklabels": true, + "side": "left", + "tick0": 0, + "tickangle": "auto", + "tickfont": { + "color": "#2a3f5f", + "family": "\"Open Sans\", verdana, arial, sans-serif", + "size": 12 + }, + "tickformat": "", + "ticklabelposition": "outside", + "tickmode": "auto", + "tickprefix": "", + "ticks": "", + "ticksuffix": "", + "title": { + "font": { + "color": "#2a3f5f", + "family": "\"Open Sans\", verdana, arial, sans-serif", + "size": 14 + }, + "standoff": 15, + "text": "Click to enter Y axis title" + }, + "type": "linear", + "visible": true, + "zeroline": true, + "zerolinecolor": "#EBF0F8", + "zerolinewidth": 2 + } + }, + "layout_edit_id": 6 + }, + "_js2py_restyle": {}, + "_js2py_update": {}, + "_last_layout_edit_id": 6, + "_last_trace_edit_id": 1, + "_layout": { + "height": 300, + "legend": { + "orientation": "h" + }, + "margin": { + "b": 10, + "l": 45, + "pad": 3, + "r": 5, + "t": 25 + }, + "template": { + "data": { + "bar": [ + { + "error_x": { + "color": "#2a3f5f" + }, + "error_y": { + "color": "#2a3f5f" + }, + "marker": { + "line": { + "color": "white", + "width": 0.5 + } + }, + "type": "bar" + } + ], + "barpolar": [ + { + "marker": { + "line": { + "color": "white", + "width": 0.5 + } + }, + "type": "barpolar" + } + ], + "carpet": [ + { + "aaxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "#C8D4E3", + "linecolor": "#C8D4E3", + "minorgridcolor": "#C8D4E3", + "startlinecolor": "#2a3f5f" + }, + "baxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "#C8D4E3", + "linecolor": "#C8D4E3", + "minorgridcolor": "#C8D4E3", + "startlinecolor": "#2a3f5f" + }, + "type": "carpet" + } + ], + "choropleth": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "choropleth" + } + ], + "contour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "contour" + } + ], + "contourcarpet": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "contourcarpet" + } + ], + "heatmap": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "heatmap" + } + ], + "heatmapgl": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "heatmapgl" + } + ], + "histogram": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "histogram" + } + ], + "histogram2d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "histogram2d" + } + ], + "histogram2dcontour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "histogram2dcontour" + } + ], + "mesh3d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "mesh3d" + } + ], + "parcoords": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "parcoords" + } + ], + "pie": [ + { + "automargin": true, + "type": "pie" + } + ], + "scatter": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatter" + } + ], + "scatter3d": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatter3d" + } + ], + "scattercarpet": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattercarpet" + } + ], + "scattergeo": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergeo" + } + ], + "scattergl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergl" + } + ], + "scattermapbox": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermapbox" + } + ], + "scatterpolar": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolar" + } + ], + "scatterpolargl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolargl" + } + ], + "scatterternary": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterternary" + } + ], + "surface": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "type": "surface" + } + ], + "table": [ + { + "cells": { + "fill": { + "color": "#EBF0F8" + }, + "line": { + "color": "white" + } + }, + "header": { + "fill": { + "color": "#C8D4E3" + }, + "line": { + "color": "white" + } + }, + "type": "table" + } + ] + }, + "layout": { + "annotationdefaults": { + "arrowcolor": "#2a3f5f", + "arrowhead": 0, + "arrowwidth": 1 + }, + "autotypenumbers": "strict", + "coloraxis": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "colorscale": { + "diverging": [ + [ + 0, + "#8e0152" + ], + [ + 0.1, + "#c51b7d" + ], + [ + 0.2, + "#de77ae" + ], + [ + 0.3, + "#f1b6da" + ], + [ + 0.4, + "#fde0ef" + ], + [ + 0.5, + "#f7f7f7" + ], + [ + 0.6, + "#e6f5d0" + ], + [ + 0.7, + "#b8e186" + ], + [ + 0.8, + "#7fbc41" + ], + [ + 0.9, + "#4d9221" + ], + [ + 1, + "#276419" + ] + ], + "sequential": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ], + "sequentialminus": [ + [ + 0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1, + "#f0f921" + ] + ] + }, + "colorway": [ + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", + "#FF97FF", + "#FECB52" + ], + "font": { + "color": "#2a3f5f" + }, + "geo": { + "bgcolor": "white", + "lakecolor": "white", + "landcolor": "white", + "showlakes": true, + "showland": true, + "subunitcolor": "#C8D4E3" + }, + "hoverlabel": { + "align": "left" + }, + "hovermode": "closest", + "mapbox": { + "style": "light" + }, + "paper_bgcolor": "white", + "plot_bgcolor": "white", + "polar": { + "angularaxis": { + "gridcolor": "#EBF0F8", + "linecolor": "#EBF0F8", + "ticks": "" + }, + "bgcolor": "white", + "radialaxis": { + "gridcolor": "#EBF0F8", + "linecolor": "#EBF0F8", + "ticks": "" + } + }, + "scene": { + "xaxis": { + "backgroundcolor": "white", + "gridcolor": "#DFE8F3", + "gridwidth": 2, + "linecolor": "#EBF0F8", + "showbackground": true, + "ticks": "", + "zerolinecolor": "#EBF0F8" + }, + "yaxis": { + "backgroundcolor": "white", + "gridcolor": "#DFE8F3", + "gridwidth": 2, + "linecolor": "#EBF0F8", + "showbackground": true, + "ticks": "", + "zerolinecolor": "#EBF0F8" + }, + "zaxis": { + "backgroundcolor": "white", + "gridcolor": "#DFE8F3", + "gridwidth": 2, + "linecolor": "#EBF0F8", + "showbackground": true, + "ticks": "", + "zerolinecolor": "#EBF0F8" + } + }, + "shapedefaults": { + "line": { + "color": "#2a3f5f" + } + }, + "ternary": { + "aaxis": { + "gridcolor": "#DFE8F3", + "linecolor": "#A2B1C6", + "ticks": "" + }, + "baxis": { + "gridcolor": "#DFE8F3", + "linecolor": "#A2B1C6", + "ticks": "" + }, + "bgcolor": "white", + "caxis": { + "gridcolor": "#DFE8F3", + "linecolor": "#A2B1C6", + "ticks": "" + } + }, + "title": { + "x": 0.05 + }, + "xaxis": { + "automargin": true, + "gridcolor": "#EBF0F8", + "linecolor": "#EBF0F8", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "#EBF0F8", + "zerolinewidth": 2 + }, + "yaxis": { + "automargin": true, + "gridcolor": "#EBF0F8", + "linecolor": "#EBF0F8", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "#EBF0F8", + "zerolinewidth": 2 + } + } + }, + "xaxis": { + "autorange": true, + "showspikes": false + }, + "yaxis": { + "autorange": true, + "showspikes": false + } + }, + "_py2js_animate": {}, + "_py2js_deleteTraces": {}, + "_py2js_moveTraces": {}, + "_py2js_removeTraceProps": {}, + "_py2js_restyle": {}, + "_py2js_update": {}, + "_view_count": 1 + } + } + }, + "version_major": 2, + "version_minor": 0 + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +}