Skip to content

Commit e56cbc7

Browse files
jonasvddjvdd
andauthored
🌈 adding marker props (#148)
* ✨ first draft * 🧹 cleaning up * 💪 adding tests * 🔍 * 🙈 * 🖊️ review code * 🔆 fix sub attribute assignment This commit now allows sub-attribute assignment via hf_data_container * 🗽 add recursive dict attrib. assignment * 🖊️ review * 😅 updating readme * 🙈 improving replace functionality * 🙈 add gap handler to serialization * 🔥 add marker size to basic example * 🙈 fix linting * refactor: improve QOL of serialization tests * 🔍 final review --------- Co-authored-by: Jeroen Van Der Donckt <[email protected]> Co-authored-by: Jeroen Van Der Donckt <[email protected]>
1 parent a179f05 commit e56cbc7

File tree

7 files changed

+500
-375
lines changed

7 files changed

+500
-375
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ Paper (preprint): https://arxiv.org/abs/2206.08703
158158
## Future work 🔨
159159

160160
- [x] Support `.add_traces()` (currently only `.add_trace` is supported)
161-
- [ ] Support `hf_color` and `hf_markersize`, see [#148](https://github.com/predict-idlab/plotly-resampler/pull/148)
161+
- [x] Support `hf_color` and `hf_markersize`, see [#148](https://github.com/predict-idlab/plotly-resampler/pull/148)
162162
- [x] Integrate with [tsdownsample](https://github.com/predict-idlab/tsdownsample) :racehorse:
163163

164164
<br>

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ The [basic example notebook](basic_example.ipynb) covers most use-cases in which
1717

1818
Additionally, this notebook also shows some more advanced functionalities, such as:
1919
* Retaining (a static) plotly-resampler figure in your notebook
20+
* Showing how to style the marker color and size of plotly-resampler figures
2021
* Adjusting trace data of plotly-resampler figures at runtime
2122
* How to add (shaded) confidence bounds to your time series
2223
* The flexibility of configuring different aggregation-algorithms and number of shown samples per trace

examples/basic_example.ipynb

Lines changed: 241 additions & 126 deletions
Large diffs are not rendered by default.

examples/dash_apps/01_minimal_global.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ def plot_graph(n_clicks):
5757
if len(ctx.triggered) and "plot-button" in ctx.triggered[0]["prop_id"]:
5858
# Note how the replace method is used here on the global figure object
5959
global fig
60-
fig.replace(go.Figure())
60+
if len(fig.data):
61+
# Replace the figure with an empty one to clear the graph
62+
fig.replace(go.Figure())
6163
fig.add_trace(go.Scattergl(name="log"), hf_x=x, hf_y=noisy_sin * 0.9999995**x)
6264
fig.add_trace(go.Scattergl(name="exp"), hf_x=x, hf_y=noisy_sin * 1.000002**x)
6365
return fig

plotly_resampler/figure_resampler/figure_resampler_interface.py

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@
3131
from ..aggregation.plotly_aggregator_parser import PlotlyAggregatorParser
3232
from .utils import round_number_str, round_td_str
3333

34-
_hf_data_container = namedtuple("DataContainer", ["x", "y", "text", "hovertext"])
34+
# A high-frequency data container
35+
# NOTE: the attributes must all be valid trace attributes, with attribute levels
36+
# separated by an '_' (e.g., 'marker_color' is valid) as the
37+
# `_hf_data_container._asdict()` function is used in
38+
# `AbstractFigureAggregator._construct_hf_data_dict`.
39+
_hf_data_container = namedtuple(
40+
"DataContainer", ["x", "y", "text", "hovertext", "marker_size", "marker_color"]
41+
)
3542

3643

3744
class AbstractFigureAggregator(BaseFigure, ABC):
@@ -355,14 +362,22 @@ def _check_update_trace_data(
355362
hf_trace_data, end_idx - start_idx, agg_x
356363
)
357364

365+
def _nest_dict_rec(k: str, v: any, out: dict) -> None:
366+
"""Recursively nest a dict based on the key whose '_' indicates level."""
367+
k, *rest = k.split("_", 1)
368+
if rest:
369+
_nest_dict_rec(rest[0], v, out.setdefault(k, {}))
370+
else:
371+
out[k] = v
372+
358373
# Check if (hover)text also needs to be downsampled
359-
for k in ["text", "hovertext"]:
374+
for k in ["text", "hovertext", "marker_size", "marker_color"]:
360375
k_val = hf_trace_data.get(k)
361376
if isinstance(k_val, (np.ndarray, pd.Series)):
362377
assert isinstance(
363378
hf_trace_data["downsampler"], DataPointSelector
364379
), "Only DataPointSelector can downsample non-data trace array props."
365-
trace[k] = k_val[start_idx + indices]
380+
_nest_dict_rec(k, k_val[start_idx + indices], trace)
366381
elif k_val is not None:
367382
trace[k] = k_val
368383

@@ -535,6 +550,8 @@ def _parse_get_trace_props(
535550
hf_y: Iterable = None,
536551
hf_text: Iterable = None,
537552
hf_hovertext: Iterable = None,
553+
hf_marker_size: Iterable = None,
554+
hf_marker_color: Iterable = None,
538555
check_nans: bool = True,
539556
) -> _hf_data_container:
540557
"""Parse and capture the possibly high-frequency trace-props in a datacontainer.
@@ -601,6 +618,26 @@ def _parse_get_trace_props(
601618
else None
602619
)
603620

621+
hf_marker_size = (
622+
trace["marker"]["size"]
623+
if (
624+
hf_marker_size is None
625+
and hasattr(trace, "marker")
626+
and "size" in trace["marker"]
627+
)
628+
else hf_marker_size
629+
)
630+
631+
hf_marker_color = (
632+
trace["marker"]["color"]
633+
if (
634+
hf_marker_color is None
635+
and hasattr(trace, "marker")
636+
and "color" in trace["marker"]
637+
)
638+
else hf_marker_color
639+
)
640+
604641
if trace["type"].lower() in self._high_frequency_traces:
605642
if hf_x is None: # if no data as x or hf_x is passed
606643
if hf_y.ndim != 0: # if hf_y is an array
@@ -687,8 +724,15 @@ def _parse_get_trace_props(
687724

688725
if hasattr(trace, "hovertext"):
689726
trace["hovertext"] = hf_hovertext
690-
691-
return _hf_data_container(hf_x, hf_y, hf_text, hf_hovertext)
727+
if hasattr(trace, "marker"):
728+
if hasattr(trace.marker, "size"):
729+
trace.marker.size = hf_marker_size
730+
if hasattr(trace.marker, "color"):
731+
trace.marker.color = hf_marker_color
732+
733+
return _hf_data_container(
734+
hf_x, hf_y, hf_text, hf_hovertext, hf_marker_size, hf_marker_color
735+
)
692736

693737
def _construct_hf_data_dict(
694738
self,
@@ -800,6 +844,8 @@ def add_trace(
800844
hf_y: Iterable = None,
801845
hf_text: Union[str, Iterable] = None,
802846
hf_hovertext: Union[str, Iterable] = None,
847+
hf_marker_size: Union[str, Iterable] = None,
848+
hf_marker_color: Union[str, Iterable] = None,
803849
check_nans: bool = True,
804850
**trace_kwargs,
805851
):
@@ -850,6 +896,12 @@ def add_trace(
850896
hf_hovertext: Iterable, optional
851897
The original high frequency hovertext. If set, this has priority over the
852898
trace its ```hovertext`` argument.
899+
hf_marker_size: Iterable, optional
900+
The original high frequency marker size. If set, this has priority over the
901+
trace its ``marker.size`` argument.
902+
hf_marker_color: Iterable, optional
903+
The original high frequency marker color. If set, this has priority over the
904+
trace its ``marker.color`` argument.
853905
check_nans: boolean, optional
854906
If set to True, the trace's data will be checked for NaNs - which will be
855907
removed. By default True.
@@ -929,7 +981,14 @@ def add_trace(
929981
# construct the hf_data_container
930982
# TODO in future version -> maybe regex on kwargs which start with `hf_`
931983
dc = self._parse_get_trace_props(
932-
trace, hf_x, hf_y, hf_text, hf_hovertext, check_nans
984+
trace,
985+
hf_x,
986+
hf_y,
987+
hf_text,
988+
hf_hovertext,
989+
hf_marker_size,
990+
hf_marker_color,
991+
check_nans,
933992
)
934993

935994
# These traces will determine the autoscale its RANGE!
@@ -955,6 +1014,8 @@ def add_trace(
9551014
# We copy (by reference) all the non-data properties of the trace in
9561015
# the new trace.
9571016
trace = trace._props # convert the trace into a dict
1017+
# NOTE: there is no need to store `marker` property here.
1018+
# If needed, it will be added to `trace` via `check_update_trace_data`
9581019
trace = {
9591020
k: trace[k] for k in set(trace.keys()).difference(set(dc._fields))
9601021
}
@@ -970,12 +1031,12 @@ def add_trace(
9701031
)
9711032
else:
9721033
self._print(f"[i] NOT resampling {trace['name']} - len={n_samples}")
973-
for k in dc._fields:
974-
setattr(trace, k, getattr(dc, k))
1034+
trace._process_kwargs(**{k: getattr(dc, k) for k in dc._fields})
9751035
return super(AbstractFigureAggregator, self).add_traces(
9761036
[trace], **self._add_trace_to_add_traces_kwargs(trace_kwargs)
9771037
)
978-
return super(AbstractFigureAggregator, self).add_traces(
1038+
1039+
return super(self._figure_class, self).add_traces(
9791040
[trace], **self._add_trace_to_add_traces_kwargs(trace_kwargs)
9801041
)
9811042

@@ -1184,7 +1245,10 @@ def replace(self, figure: go.Figure, convert_existing_traces: bool = True):
11841245
convert_existing_traces=convert_existing_traces,
11851246
default_n_shown_samples=self._global_n_shown_samples,
11861247
default_downsampler=self._global_downsampler,
1248+
default_gap_handler=self._global_gap_handler,
11871249
resampled_trace_prefix_suffix=(self._prefix, self._suffix),
1250+
show_mean_aggregation_size=self._show_mean_aggregation_size,
1251+
verbose=self._print_verbose,
11881252
)
11891253

11901254
def _parse_relayout(self, relayout_dict: dict) -> dict:
@@ -1299,7 +1363,7 @@ def construct_update_data(
12991363
layout_traces_list: List[dict] = [relayout_data]
13001364

13011365
# 2. Create the additional trace data for the frond-end
1302-
relevant_keys = list(_hf_data_container._fields) + ["name"]
1366+
relevant_keys = list(_hf_data_container._fields) + ["name", "marker"]
13031367
# Note that only updated trace-data will be sent to the client
13041368
for idx in updated_trace_indices:
13051369
trace = current_graph["data"][idx]
@@ -1355,6 +1419,7 @@ def _get_pr_props_keys(self) -> List[str]:
13551419
"_prefix",
13561420
"_suffix",
13571421
"_global_downsampler",
1422+
"_global_gap_handler",
13581423
]
13591424

13601425
def __reduce__(self):

tests/test_figure_resampler.py

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from selenium.webdriver.common.by import By
1818

1919
from plotly_resampler import LTTB, EveryNthPoint, FigureResampler
20-
from plotly_resampler.aggregation import PlotlyAggregatorParser
20+
from plotly_resampler.aggregation import NoGapHandler, PlotlyAggregatorParser
2121

2222
# Note: this will be used to skip / alter behavior when running browser tests on
2323
# non-linux platforms.
@@ -329,6 +329,30 @@ def test_replace_figure(float_series):
329329
assert len(go_fig.data[0]["x"]) == len(float_series)
330330

331331

332+
def test_replace_properties(float_series):
333+
resampled_trace_prefix_suffix = ("a", "b")
334+
verbose = True
335+
default_n_shown_samples = 1050
336+
default_gap_handler = NoGapHandler()
337+
default_downsampler = EveryNthPoint()
338+
fr_fig = FigureResampler(
339+
default_n_shown_samples=default_n_shown_samples,
340+
verbose=verbose,
341+
resampled_trace_prefix_suffix=resampled_trace_prefix_suffix,
342+
default_gap_handler=default_gap_handler,
343+
default_downsampler=default_downsampler,
344+
)
345+
346+
fr_fig.add_trace(go.Scattergl(x=float_series.index, y=float_series, name="fs"))
347+
fr_fig.replace(go.Figure())
348+
349+
assert fr_fig._global_n_shown_samples == default_n_shown_samples
350+
assert fr_fig._print_verbose == verbose
351+
assert (fr_fig._prefix, fr_fig._suffix) == resampled_trace_prefix_suffix
352+
assert fr_fig._global_gap_handler == default_gap_handler
353+
assert fr_fig._global_downsampler == default_downsampler
354+
355+
332356
def test_nan_removed_input(float_series):
333357
# see: https://plotly.com/python/subplots/#custom-sized-subplot-with-subplot-titles
334358
base_fig = make_subplots(
@@ -1559,3 +1583,84 @@ def test_fr_copy_grid():
15591583
assert fr._grid_ref == f_dict.get("_grid_ref")
15601584
assert fr._grid_str is not None
15611585
assert fr._grid_str == f_dict.get("_grid_str")
1586+
1587+
1588+
# Testing HF marker_size and color arguments
1589+
def test_hf_marker_size_hf_args():
1590+
# create dummy data
1591+
n = 100_000
1592+
y = np.sin(np.arange(n) / 2_000) + np.random.randn(n) / 10
1593+
1594+
# construct the figure via hf kwargs
1595+
fr = FigureResampler()
1596+
fr.add_trace(
1597+
go.Scattergl(mode="markers"),
1598+
hf_y=y,
1599+
hf_marker_size=(3 + 20 * np.abs(y)).astype(int),
1600+
hf_marker_color=np.abs(y) / np.max(np.abs(y)),
1601+
)
1602+
1603+
# Perform asserts on the hf_data part of the figure
1604+
hf_trace = fr.hf_data[0]
1605+
assert "marker_size" in hf_trace
1606+
assert "marker_color" in hf_trace
1607+
1608+
assert len(hf_trace["marker_size"] == len(y))
1609+
assert len(hf_trace["marker_color"] == len(y))
1610+
1611+
# perform some asserts on the to-be constructed update data
1612+
update_trace = fr.construct_update_data(
1613+
{"xaxis.autorange": True, "xaxis.showspikes": True}
1614+
)[1]
1615+
1616+
assert all(k in update_trace for k in ["x", "y", "name", "marker", "index"])
1617+
1618+
# check whether the marker size and marker color are available
1619+
assert all(k in update_trace["marker"] for k in ["size", "color"])
1620+
assert len(update_trace["marker"]["size"]) == len(update_trace["x"])
1621+
assert np.allclose(
1622+
update_trace["marker"]["color"],
1623+
(np.abs(update_trace["y"]) / np.max(np.abs(y))),
1624+
rtol=1e-3,
1625+
)
1626+
1627+
1628+
def test_hf_marker_size_plotly_args():
1629+
# create dummy data
1630+
n = 100_000
1631+
y = np.sin(np.arange(n) / 2_000) + np.random.randn(n) / 10
1632+
1633+
# construct the figure via hf kwargs
1634+
fr = FigureResampler()
1635+
fr.add_trace(
1636+
go.Scattergl(
1637+
mode="markers",
1638+
marker_size=(3 + 20 * np.abs(y)).astype(int),
1639+
marker_color=np.abs(y) / np.max(np.abs(y)),
1640+
),
1641+
hf_y=y,
1642+
)
1643+
1644+
# Perform asserts on the hf_data part of the figure
1645+
hf_trace = fr.hf_data[0]
1646+
assert "marker_size" in hf_trace
1647+
assert "marker_color" in hf_trace
1648+
1649+
assert len(hf_trace["marker_size"] == len(y))
1650+
assert len(hf_trace["marker_color"] == len(y))
1651+
1652+
# perform some asserts on the to-be constructed update data
1653+
update_trace = fr.construct_update_data(
1654+
{"xaxis.autorange": True, "xaxis.showspikes": True}
1655+
)[1]
1656+
1657+
assert all(k in update_trace for k in ["x", "y", "name", "marker", "index"])
1658+
1659+
# check whether the marker size and marker color are available
1660+
assert all(k in update_trace["marker"] for k in ["size", "color"])
1661+
assert len(update_trace["marker"]["size"]) == len(update_trace["x"])
1662+
assert np.allclose(
1663+
update_trace["marker"]["color"],
1664+
(np.abs(update_trace["y"]) / np.max(np.abs(y))),
1665+
rtol=1e-3,
1666+
)

0 commit comments

Comments
 (0)