Skip to content

Commit 9483326

Browse files
jonasvddjvdd
andauthored
Generic hf properties (#355)
* 💪 Somewhat generic fix for #354 - Added support for new downsamplable properties: `marker_symbol` and `hf_marker_symbol`. - Refactored the handling of high-frequency properties to be more dynamic and extensible. - Updated the `_hf_data_container` to include new properties and adjusted parsing logic accordingly. - Introduced tests to validate the functionality of dynamic properties and their priority over trace attributes. * 🔍 Add tests similar to #354 * 💨 updating ruff and fixing linting * 🔍 fix linting in properties test * 💪 support marker angle and opacity * 🖍️ upating basic example with other hf marker properties * 🕵️ review code * 💨 removing todo & fixing small bug * 🙈 fixing formatting (command) and updating poetry lock hash * 🙈 fixing undefined property * 🙏 increase webdriver wait time for failing MacOS tests * ✨ Enhance Chrome WebDriver options for improved stability in testing * 🙏 try to fix failing selenium test on macos * 🙏 * 🙏 * 🙏 :prayl * 🙏 * 💨 skipping macos tests --------- Co-authored-by: jeroen <[email protected]>
1 parent fbd6406 commit 9483326

File tree

9 files changed

+1607
-405
lines changed

9 files changed

+1607
-405
lines changed

Makefile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ black = black plotly_resampler examples tests
22

33
.PHONY: format
44
format:
5-
ruff --fix plotly_resampler tests
6-
$(black)
5+
poetry run $(black)
6+
poetry run ruff check plotly_resampler tests
77

88
.PHONY: lint
99
lint:
10-
poetry run ruff plotly_resampler tests
10+
poetry run ruff check plotly_resampler tests
1111
poetry run $(black) --check --diff
1212

1313
.PHONY: test

examples/basic_example.ipynb

Lines changed: 565 additions & 264 deletions
Large diffs are not rendered by default.

plotly_resampler/figure_resampler/figure_resampler_interface.py

Lines changed: 138 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,31 @@
2929
from ..aggregation.plotly_aggregator_parser import PlotlyAggregatorParser
3030
from .utils import round_number_str, round_td_str
3131

32+
# Configuration for properties that can be downsampled
33+
# Each entry is a tuple of (property_name, trace_path, hf_param_name)
34+
# - property_name: the name in the _hf_data_container
35+
# - trace_path: the path to access the property in a trace (e.g., "marker.color" -> ["marker", "color"])
36+
# - hf_param_name: the parameter name for high-frequency data (e.g., "hf_marker_color")
37+
DOWNSAMPLABLE_PROPERTIES = [
38+
("text", ["text"], "hf_text"),
39+
("hovertext", ["hovertext"], "hf_hovertext"),
40+
("marker_angle", ["marker", "angle"], "hf_marker_angle"),
41+
("marker_opacity", ["marker", "opacity"], "hf_marker_opacity"),
42+
("marker_size", ["marker", "size"], "hf_marker_size"),
43+
("marker_color", ["marker", "color"], "hf_marker_color"),
44+
("marker_symbol", ["marker", "symbol"], "hf_marker_symbol"),
45+
("customdata", ["customdata"], "hf_customdata"),
46+
]
47+
3248
# A high-frequency data container
3349
# NOTE: the attributes must all be valid trace attributes, with attribute levels
3450
# separated by an '_' (e.g., 'marker_color' is valid) as the
3551
# `_hf_data_container._asdict()` function is used in
3652
# `AbstractFigureAggregator._construct_hf_data_dict`.
53+
# Create the _hf_data_container dynamically from the configuration
3754
_hf_data_container = namedtuple(
3855
"DataContainer",
39-
["x", "y", "text", "hovertext", "marker_size", "marker_color", "customdata"],
56+
["x", "y"] + [prop[0] for prop in DOWNSAMPLABLE_PROPERTIES],
4057
)
4158

4259

@@ -219,8 +236,7 @@ def _get_current_graph(self) -> dict:
219236
"data": [
220237
{
221238
k: copy(trace[k])
222-
# TODO: why not "text" as well? -> we can use _hf_data_container.fields then
223-
for k in set(trace.keys()).difference({"x", "y", "hovertext"})
239+
for k in set(trace.keys()).difference({_hf_data_container._fields})
224240
}
225241
for trace in self._data
226242
],
@@ -385,16 +401,18 @@ def _nest_dict_rec(k: str, v: any, out: dict) -> None:
385401
else:
386402
out[k] = v
387403

388-
# Check if (hover)text also needs to be downsampled
389-
for k in ["text", "hovertext", "marker_size", "marker_color", "customdata"]:
390-
k_val = hf_trace_data.get(k)
404+
# Check if downsamplable properties also need to be downsampled
405+
for prop_name, _, _ in DOWNSAMPLABLE_PROPERTIES:
406+
k_val = hf_trace_data.get(prop_name)
391407
if isinstance(k_val, (np.ndarray, pd.Series)):
392408
assert isinstance(
393409
hf_trace_data["downsampler"], DataPointSelector
394410
), "Only DataPointSelector can downsample non-data trace array props."
395-
_nest_dict_rec(k, k_val[start_idx + indices], trace)
411+
# Use the same indices that were used for x and y aggregation
412+
# indices are relative to the slice, so we need to add start_idx
413+
_nest_dict_rec(prop_name, k_val[start_idx + indices], trace)
396414
elif k_val is not None:
397-
trace[k] = k_val
415+
trace[prop_name] = k_val
398416

399417
return trace
400418

@@ -549,10 +567,7 @@ def _parse_get_trace_props(
549567
trace: BaseTraceType,
550568
hf_x: Iterable = None,
551569
hf_y: Iterable = None,
552-
hf_text: Iterable = None,
553-
hf_hovertext: Iterable = None,
554-
hf_marker_size: Iterable = None,
555-
hf_marker_color: Iterable = None,
570+
**hf_properties,
556571
) -> _hf_data_container:
557572
"""Parse and capture the possibly high-frequency trace-props in a datacontainer.
558573
@@ -603,47 +618,19 @@ def _parse_get_trace_props(
603618
if not hasattr(hf_y, "dtype"):
604619
hf_y: np.ndarray = np.asarray(hf_y)
605620

606-
hf_text = (
607-
hf_text
608-
if hf_text is not None
609-
else (
610-
trace["text"]
611-
if hasattr(trace, "text") and trace["text"] is not None
612-
else None
613-
)
614-
)
615-
616-
hf_hovertext = (
617-
hf_hovertext
618-
if hf_hovertext is not None
619-
else (
620-
trace["hovertext"]
621-
if hasattr(trace, "hovertext") and trace["hovertext"] is not None
622-
else None
623-
)
624-
)
621+
# Parse downsamplable properties dynamically
622+
parsed_properties = {}
623+
for prop_name, trace_path, hf_param_name in DOWNSAMPLABLE_PROPERTIES:
624+
# Get the high-frequency value from parameters if provided
625+
hf_value = hf_properties.get(hf_param_name)
625626

626-
hf_marker_size = (
627-
trace["marker"]["size"]
628-
if (
629-
hf_marker_size is None
630-
and hasattr(trace, "marker")
631-
and "size" in trace["marker"]
632-
)
633-
else hf_marker_size
634-
)
635-
636-
hf_marker_color = (
637-
trace["marker"]["color"]
638-
if (
639-
hf_marker_color is None
640-
and hasattr(trace, "marker")
641-
and "color" in trace["marker"]
642-
)
643-
else hf_marker_color
644-
)
645-
646-
hf_customdata = trace["customdata"] if hasattr(trace, "customdata") else None
627+
if hf_value is not None:
628+
# Use the provided high-frequency value
629+
parsed_properties[prop_name] = hf_value
630+
else:
631+
# Try to get the value from the trace
632+
trace_value = self._get_trace_property(trace, trace_path)
633+
parsed_properties[prop_name] = trace_value
647634

648635
if trace["type"].lower() in self._high_frequency_traces:
649636
if hf_x is None: # if no data as x or hf_x is passed
@@ -664,15 +651,11 @@ def _parse_get_trace_props(
664651
"(i.e., x and y, or hf_x and hf_y) to be <= 1 dimensional!"
665652
)
666653

667-
# Note: this converts the hf property to a np.ndarray
668-
if isinstance(hf_text, (tuple, list, np.ndarray, pd.Series)):
669-
hf_text = np.asarray(hf_text)
670-
if isinstance(hf_hovertext, (tuple, list, np.ndarray, pd.Series)):
671-
hf_hovertext = np.asarray(hf_hovertext)
672-
if isinstance(hf_marker_size, (tuple, list, np.ndarray, pd.Series)):
673-
hf_marker_size = np.asarray(hf_marker_size)
674-
if isinstance(hf_marker_color, (tuple, list, np.ndarray, pd.Series)):
675-
hf_marker_color = np.asarray(hf_marker_color)
654+
# Note: this converts the hf properties to np.ndarray
655+
for prop_name, _, _ in DOWNSAMPLABLE_PROPERTIES:
656+
prop_value = parsed_properties.get(prop_name)
657+
if isinstance(prop_value, (tuple, list, np.ndarray, pd.Series)):
658+
parsed_properties[prop_name] = np.asarray(prop_value)
676659

677660
# Try to parse the hf_x data if it is of object type or
678661
if len(hf_x) and (hf_x.dtype.type is np.str_ or hf_x.dtype == "object"):
@@ -719,26 +702,77 @@ def _parse_get_trace_props(
719702
if hasattr(trace, "y"):
720703
trace["y"] = hf_y
721704

722-
if hasattr(trace, "text"):
723-
trace["text"] = hf_text
724-
725-
if hasattr(trace, "hovertext"):
726-
trace["hovertext"] = 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,
735-
hf_y,
736-
hf_text,
737-
hf_hovertext,
738-
hf_marker_size,
739-
hf_marker_color,
740-
hf_customdata,
741-
)
705+
# Set downsamplable properties if they exist
706+
for prop_name, trace_path, _ in DOWNSAMPLABLE_PROPERTIES:
707+
prop_value = parsed_properties.get(prop_name)
708+
if prop_value is not None:
709+
self._set_trace_property(trace, trace_path, prop_value)
710+
711+
# Build the container with all properties
712+
container_args = [hf_x, hf_y]
713+
for prop_name, _, _ in DOWNSAMPLABLE_PROPERTIES:
714+
container_args.append(parsed_properties.get(prop_name))
715+
716+
return _hf_data_container(*container_args)
717+
718+
def _get_trace_property(self, trace: BaseTraceType, trace_path: List[str]) -> any:
719+
"""Get a property from a trace using a path.
720+
721+
Parameters
722+
----------
723+
trace : BaseTraceType
724+
The trace to get the property from.
725+
trace_path : List[str]
726+
The path to the property (e.g., ["marker", "color"]).
727+
728+
Returns
729+
-------
730+
any
731+
The property value or None if not found.
732+
"""
733+
current = trace
734+
for path_component in trace_path:
735+
if hasattr(current, path_component):
736+
current = getattr(current, path_component)
737+
elif isinstance(current, dict) and path_component in current:
738+
current = current[path_component]
739+
else:
740+
return None
741+
return current
742+
743+
def _set_trace_property(
744+
self, trace: BaseTraceType, trace_path: List[str], value: any
745+
) -> None:
746+
"""Set a property on a trace using a path.
747+
748+
Parameters
749+
----------
750+
trace : BaseTraceType
751+
The trace to set the property on.
752+
trace_path : List[str]
753+
The path to the property (e.g., ["marker", "color"]).
754+
value : any
755+
The value to set.
756+
"""
757+
current = trace
758+
for path_component in trace_path[:-1]:
759+
if hasattr(current, path_component):
760+
current = getattr(current, path_component)
761+
elif isinstance(current, dict):
762+
if path_component not in current:
763+
current[path_component] = {}
764+
current = current[path_component]
765+
else:
766+
# Create the path if it doesn't exist
767+
setattr(current, path_component, {})
768+
current = getattr(current, path_component)
769+
770+
# Set the final property
771+
final_component = trace_path[-1]
772+
if hasattr(current, final_component):
773+
setattr(current, final_component, value)
774+
elif isinstance(current, dict):
775+
current[final_component] = value
742776

743777
def _construct_hf_data_dict(
744778
self,
@@ -853,10 +887,6 @@ def add_trace(
853887
# Use these if you want some speedups (and are working with really large data)
854888
hf_x: Iterable = None,
855889
hf_y: Iterable = None,
856-
hf_text: Union[str, Iterable] = None,
857-
hf_hovertext: Union[str, Iterable] = None,
858-
hf_marker_size: Union[str, Iterable] = None,
859-
hf_marker_color: Union[str, Iterable] = None,
860890
**trace_kwargs,
861891
):
862892
"""Add a trace to the figure.
@@ -900,22 +930,21 @@ def add_trace(
900930
hf_y: Iterable, optional
901931
The original high frequency values. If set, this has priority over the
902932
trace its data.
903-
hf_text: Iterable, optional
904-
The original high frequency text. If set, this has priority over the trace
905-
its ``text`` argument.
906-
hf_hovertext: Iterable, optional
907-
The original high frequency hovertext. If set, this has priority over the
908-
trace its ```hovertext`` argument.
909-
hf_marker_size: Iterable, optional
910-
The original high frequency marker size. If set, this has priority over the
911-
trace its ``marker.size`` argument.
912-
hf_marker_color: Iterable, optional
913-
The original high frequency marker color. If set, this has priority over the
914-
trace its ``marker.color`` argument.
915933
**trace_kwargs: dict
916934
Additional trace related keyword arguments.
917935
e.g.: row=.., col=..., secondary_y=...
918936
937+
High-frequency property parameters can also be passed:
938+
- hf_text: High-frequency text data
939+
- hf_hovertext: High-frequency hovertext data
940+
- hf_marker_size: High-frequency marker size data
941+
- hf_marker_color: High-frequency marker color data
942+
- hf_marker_symbol: High-frequency marker symbol data
943+
- hf_marker_angle: High-frequency marker angle data
944+
- hf_customdata: High-frequency customdata
945+
946+
These have priority over the corresponding trace properties.
947+
919948
!!! info "See Also"
920949
[`Figure.add_trace`](https://plotly.com/python-api-reference/generated/plotly.graph_objects.Figure.html#plotly.graph_objects.Figure.add_trace>) docs.
921950
@@ -985,17 +1014,14 @@ def add_trace(
9851014
# These traces will determine the autoscale its RANGE!
9861015
# -> so also store when `limit_to_view` is set.
9871016
if trace["type"].lower() in self._high_frequency_traces:
1017+
# Extract hf_* parameters from trace_kwargs
1018+
hf_properties = {}
1019+
for _, _, hf_param_name in DOWNSAMPLABLE_PROPERTIES:
1020+
if hf_param_name in trace_kwargs:
1021+
hf_properties[hf_param_name] = trace_kwargs.pop(hf_param_name)
1022+
9881023
# construct the hf_data_container
989-
# TODO in future version -> maybe regex on kwargs which start with `hf_`
990-
dc = self._parse_get_trace_props(
991-
trace,
992-
hf_x,
993-
hf_y,
994-
hf_text,
995-
hf_hovertext,
996-
hf_marker_size,
997-
hf_marker_color,
998-
)
1024+
dc = self._parse_get_trace_props(trace, hf_x, hf_y, **hf_properties)
9991025

10001026
n_samples = len(dc.x)
10011027
if n_samples > max_out_s or limit_to_view:
@@ -1368,10 +1394,14 @@ def _construct_update_data(
13681394
layout_traces_list: List[dict] = [relayout_data]
13691395

13701396
# 2. Create the additional trace data for the frond-end
1371-
relevant_keys = list(_hf_data_container._fields) + ["name", "marker"]
1397+
relevant_keys = ["name", "marker", "x", "y"] + [
1398+
prop_name for prop_name, _, _ in DOWNSAMPLABLE_PROPERTIES
1399+
]
1400+
# self._print("relevant keys", relevant_keys)
13721401
# Note that only updated trace-data will be sent to the client
13731402
for idx in updated_trace_indices:
13741403
trace = current_graph["data"][idx]
1404+
# self._print("trace keys", dict(trace).keys())
13751405
# TODO: check if we can reduce even more
13761406
trace_reduced = {k: trace[k] for k in relevant_keys if k in trace}
13771407

0 commit comments

Comments
 (0)