Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ black = black plotly_resampler examples tests

.PHONY: format
format:
ruff --fix plotly_resampler tests
$(black)
poetry run ruff check plotly_resampler tests

.PHONY: lint
lint:
poetry run ruff plotly_resampler tests
poetry run ruff check plotly_resampler tests
poetry run $(black) --check --diff

.PHONY: test
Expand Down
829 changes: 565 additions & 264 deletions examples/basic_example.ipynb

Large diffs are not rendered by default.

244 changes: 138 additions & 106 deletions plotly_resampler/figure_resampler/figure_resampler_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,31 @@
from ..aggregation.plotly_aggregator_parser import PlotlyAggregatorParser
from .utils import round_number_str, round_td_str

# Configuration for properties that can be downsampled
# Each entry is a tuple of (property_name, trace_path, hf_param_name)
# - property_name: the name in the _hf_data_container
# - trace_path: the path to access the property in a trace (e.g., "marker.color" -> ["marker", "color"])
# - hf_param_name: the parameter name for high-frequency data (e.g., "hf_marker_color")
DOWNSAMPLABLE_PROPERTIES = [
("text", ["text"], "hf_text"),
("hovertext", ["hovertext"], "hf_hovertext"),
("marker_angle", ["marker", "angle"], "hf_marker_angle"),
("marker_opacity", ["marker", "opacity"], "hf_marker_opacity"),
("marker_size", ["marker", "size"], "hf_marker_size"),
("marker_color", ["marker", "color"], "hf_marker_color"),
("marker_symbol", ["marker", "symbol"], "hf_marker_symbol"),
("customdata", ["customdata"], "hf_customdata"),
]

# A high-frequency data container
# NOTE: the attributes must all be valid trace attributes, with attribute levels
# separated by an '_' (e.g., 'marker_color' is valid) as the
# `_hf_data_container._asdict()` function is used in
# `AbstractFigureAggregator._construct_hf_data_dict`.
# Create the _hf_data_container dynamically from the configuration
_hf_data_container = namedtuple(
"DataContainer",
["x", "y", "text", "hovertext", "marker_size", "marker_color", "customdata"],
["x", "y"] + [prop[0] for prop in DOWNSAMPLABLE_PROPERTIES],
)


Expand Down Expand Up @@ -385,16 +402,18 @@ def _nest_dict_rec(k: str, v: any, out: dict) -> None:
else:
out[k] = v

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

return trace

Expand Down Expand Up @@ -549,10 +568,7 @@ def _parse_get_trace_props(
trace: BaseTraceType,
hf_x: Iterable = None,
hf_y: Iterable = None,
hf_text: Iterable = None,
hf_hovertext: Iterable = None,
hf_marker_size: Iterable = None,
hf_marker_color: Iterable = None,
**hf_properties,
) -> _hf_data_container:
"""Parse and capture the possibly high-frequency trace-props in a datacontainer.

Expand Down Expand Up @@ -603,47 +619,19 @@ def _parse_get_trace_props(
if not hasattr(hf_y, "dtype"):
hf_y: np.ndarray = np.asarray(hf_y)

hf_text = (
hf_text
if hf_text is not None
else (
trace["text"]
if hasattr(trace, "text") and trace["text"] is not None
else None
)
)

hf_hovertext = (
hf_hovertext
if hf_hovertext is not None
else (
trace["hovertext"]
if hasattr(trace, "hovertext") and trace["hovertext"] is not None
else None
)
)
# Parse downsamplable properties dynamically
parsed_properties = {}
for prop_name, trace_path, hf_param_name in DOWNSAMPLABLE_PROPERTIES:
# Get the high-frequency value from parameters if provided
hf_value = hf_properties.get(hf_param_name)

hf_marker_size = (
trace["marker"]["size"]
if (
hf_marker_size is None
and hasattr(trace, "marker")
and "size" in trace["marker"]
)
else hf_marker_size
)

hf_marker_color = (
trace["marker"]["color"]
if (
hf_marker_color is None
and hasattr(trace, "marker")
and "color" in trace["marker"]
)
else hf_marker_color
)

hf_customdata = trace["customdata"] if hasattr(trace, "customdata") else None
if hf_value is not None:
# Use the provided high-frequency value
parsed_properties[prop_name] = hf_value
else:
# Try to get the value from the trace
trace_value = self._get_trace_property(trace, trace_path)
parsed_properties[prop_name] = trace_value

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

# Note: this converts the hf property to a np.ndarray
if isinstance(hf_text, (tuple, list, np.ndarray, pd.Series)):
hf_text = np.asarray(hf_text)
if isinstance(hf_hovertext, (tuple, list, np.ndarray, pd.Series)):
hf_hovertext = np.asarray(hf_hovertext)
if isinstance(hf_marker_size, (tuple, list, np.ndarray, pd.Series)):
hf_marker_size = np.asarray(hf_marker_size)
if isinstance(hf_marker_color, (tuple, list, np.ndarray, pd.Series)):
hf_marker_color = np.asarray(hf_marker_color)
# Note: this converts the hf properties to np.ndarray
for prop_name, _, _ in DOWNSAMPLABLE_PROPERTIES:
prop_value = parsed_properties.get(prop_name)
if isinstance(prop_value, (tuple, list, np.ndarray, pd.Series)):
parsed_properties[prop_name] = np.asarray(prop_value)

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

if hasattr(trace, "text"):
trace["text"] = hf_text

if hasattr(trace, "hovertext"):
trace["hovertext"] = hf_hovertext
if hasattr(trace, "marker"):
if hasattr(trace.marker, "size"):
trace.marker.size = hf_marker_size
if hasattr(trace.marker, "color"):
trace.marker.color = hf_marker_color

return _hf_data_container(
hf_x,
hf_y,
hf_text,
hf_hovertext,
hf_marker_size,
hf_marker_color,
hf_customdata,
)
# Set downsamplable properties if they exist
for prop_name, trace_path, _ in DOWNSAMPLABLE_PROPERTIES:
prop_value = parsed_properties.get(prop_name)
if prop_value is not None:
self._set_trace_property(trace, trace_path, prop_value)

# Build the container with all properties
container_args = [hf_x, hf_y]
for prop_name, _, _ in DOWNSAMPLABLE_PROPERTIES:
container_args.append(parsed_properties.get(prop_name))

return _hf_data_container(*container_args)

def _get_trace_property(self, trace: BaseTraceType, trace_path: List[str]) -> any:
"""Get a property from a trace using a path.

Parameters
----------
trace : BaseTraceType
The trace to get the property from.
trace_path : List[str]
The path to the property (e.g., ["marker", "color"]).

Returns
-------
any
The property value or None if not found.
"""
current = trace
for path_component in trace_path:
if hasattr(current, path_component):
current = getattr(current, path_component)
elif isinstance(current, dict) and path_component in current:
current = current[path_component]
else:
return None
return current

def _set_trace_property(
self, trace: BaseTraceType, trace_path: List[str], value: any
) -> None:
"""Set a property on a trace using a path.

Parameters
----------
trace : BaseTraceType
The trace to set the property on.
trace_path : List[str]
The path to the property (e.g., ["marker", "color"]).
value : any
The value to set.
"""
current = trace
for i, path_component in enumerate(trace_path[:-1]):
if hasattr(current, path_component):
current = getattr(current, path_component)
elif isinstance(current, dict):
if path_component not in current:
current[path_component] = {}
current = current[path_component]
else:
# Create the path if it doesn't exist
setattr(current, path_component, {})
current = getattr(current, path_component)

# Set the final property
final_component = trace_path[-1]
if hasattr(current, final_component):
setattr(current, final_component, value)
elif isinstance(current, dict):
current[final_component] = value

def _construct_hf_data_dict(
self,
Expand Down Expand Up @@ -853,10 +888,6 @@ def add_trace(
# Use these if you want some speedups (and are working with really large data)
hf_x: Iterable = None,
hf_y: Iterable = None,
hf_text: Union[str, Iterable] = None,
hf_hovertext: Union[str, Iterable] = None,
hf_marker_size: Union[str, Iterable] = None,
hf_marker_color: Union[str, Iterable] = None,
**trace_kwargs,
):
"""Add a trace to the figure.
Expand Down Expand Up @@ -900,22 +931,21 @@ def add_trace(
hf_y: Iterable, optional
The original high frequency values. If set, this has priority over the
trace its data.
hf_text: Iterable, optional
The original high frequency text. If set, this has priority over the trace
its ``text`` argument.
hf_hovertext: Iterable, optional
The original high frequency hovertext. If set, this has priority over the
trace its ```hovertext`` argument.
hf_marker_size: Iterable, optional
The original high frequency marker size. If set, this has priority over the
trace its ``marker.size`` argument.
hf_marker_color: Iterable, optional
The original high frequency marker color. If set, this has priority over the
trace its ``marker.color`` argument.
**trace_kwargs: dict
Additional trace related keyword arguments.
e.g.: row=.., col=..., secondary_y=...

High-frequency property parameters can also be passed:
- hf_text: High-frequency text data
- hf_hovertext: High-frequency hovertext data
- hf_marker_size: High-frequency marker size data
- hf_marker_color: High-frequency marker color data
- hf_marker_symbol: High-frequency marker symbol data
- hf_marker_angle: High-frequency marker angle data
- hf_customdata: High-frequency customdata

These have priority over the corresponding trace properties.

!!! info "See Also"
[`Figure.add_trace`](https://plotly.com/python-api-reference/generated/plotly.graph_objects.Figure.html#plotly.graph_objects.Figure.add_trace>) docs.

Expand Down Expand Up @@ -985,17 +1015,15 @@ def add_trace(
# These traces will determine the autoscale its RANGE!
# -> so also store when `limit_to_view` is set.
if trace["type"].lower() in self._high_frequency_traces:
# Extract hf_* parameters from trace_kwargs
hf_properties = {}
for _, _, hf_param_name in DOWNSAMPLABLE_PROPERTIES:
if hf_param_name in trace_kwargs:
# TODO -> hf_param name
hf_properties[hf_param_name] = trace_kwargs.pop(hf_param_name)

# construct the hf_data_container
# TODO in future version -> maybe regex on kwargs which start with `hf_`
dc = self._parse_get_trace_props(
trace,
hf_x,
hf_y,
hf_text,
hf_hovertext,
hf_marker_size,
hf_marker_color,
)
dc = self._parse_get_trace_props(trace, hf_x, hf_y, **hf_properties)

n_samples = len(dc.x)
if n_samples > max_out_s or limit_to_view:
Expand Down Expand Up @@ -1368,10 +1396,14 @@ def _construct_update_data(
layout_traces_list: List[dict] = [relayout_data]

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

Expand Down
Loading
Loading