From 16fc3936a4005513c6afebfbbf7919445d7032ac Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Fri, 31 May 2024 13:04:19 -0700 Subject: [PATCH] [REF-2202] Implement event handlers for Plotly (#3397) * pyi_generator: do not generate kwargs for event trigger props event triggers are handled separately * Implement event handlers for Plotly * py38 compat: from __future__ import annotations --- reflex/components/plotly/plotly.py | 172 ++++++++++++++++++++++++++++ reflex/components/plotly/plotly.pyi | 62 ++++++++++ reflex/utils/pyi_generator.py | 2 + 3 files changed, 236 insertions(+) diff --git a/reflex/components/plotly/plotly.py b/reflex/components/plotly/plotly.py index 3ee1977c3..762172ed6 100644 --- a/reflex/components/plotly/plotly.py +++ b/reflex/components/plotly/plotly.py @@ -1,8 +1,11 @@ """Component for displaying a plotly graph.""" +from __future__ import annotations from typing import Any, Dict, List +from reflex.base import Base from reflex.components.component import NoSSRComponent +from reflex.event import EventHandler from reflex.vars import Var try: @@ -11,6 +14,76 @@ except ImportError: Figure = Any # type: ignore +def _event_data_signature(e0: Var) -> List[Any]: + """For plotly events with event data and no points. + + Args: + e0: The event data. + + Returns: + The event key extracted from the event data (if defined). + """ + return [Var.create_safe(f"{e0}?.event")] + + +def _event_points_data_signature(e0: Var) -> List[Any]: + """For plotly events with event data containing a point array. + + Args: + e0: The event data. + + Returns: + The event data and the extracted points. + """ + return [ + Var.create_safe(f"{e0}?.event"), + Var.create_safe( + f"extractPoints({e0}?.points)", + ), + ] + + +class _ButtonClickData(Base): + """Event data structure for plotly UI buttons.""" + + menu: Any + button: Any + active: Any + + +def _button_click_signature(e0: _ButtonClickData) -> List[Any]: + """For plotly button click events. + + Args: + e0: The button click data. + + Returns: + The menu, button, and active state. + """ + return [e0.menu, e0.button, e0.active] + + +def _passthrough_signature(e0: Var) -> List[Any]: + """For plotly events with arbitrary serializable data, passed through directly. + + Args: + e0: The event data. + + Returns: + The event data. + """ + return [e0] + + +def _null_signature() -> List[Any]: + """For plotly events with no data or non-serializable data. Nothing passed through. + + Returns: + An empty list (nothing passed through). + """ + return [] + + class PlotlyLib(NoSSRComponent): """A component that wraps a plotly lib.""" @@ -38,6 +111,105 @@ class Plotly(PlotlyLib): # If true, the graph will resize when the window is resized. use_resize_handler: Var[bool] + # Fired after the plot is redrawn. + on_after_plot: EventHandler[_passthrough_signature] + + # Fired after the plot was animated. + on_animated: EventHandler[_null_signature] + + # Fired while animating a single frame (does not currently pass data through). + on_animating_frame: EventHandler[_null_signature] + + # Fired when an animation is interrupted (to start a new animation for example). + on_animation_interrupted: EventHandler[_null_signature] + + # Fired when the plot is responsively sized. + on_autosize: EventHandler[_event_data_signature] + + # Fired whenever mouse moves over a plot. + on_before_hover: EventHandler[_event_data_signature] + + # Fired when a plotly UI button is clicked. + on_button_clicked: EventHandler[_button_click_signature] + + # Fired when the plot is clicked. + on_click: EventHandler[_event_points_data_signature] + + # Fired when a selection is cleared (via double click). + on_deselect: EventHandler[_null_signature] + + # Fired when the plot is double clicked. + on_double_click: EventHandler[_passthrough_signature] + + # Fired when a plot element is hovered over. + on_hover: EventHandler[_event_points_data_signature] + + # Fired after the plot is layed out (zoom, pan, etc). + on_relayout: EventHandler[_passthrough_signature] + + # Fired while the plot is being layed out. + on_relayouting: EventHandler[_passthrough_signature] + + # Fired after the plot style is changed. + on_restyle: EventHandler[_passthrough_signature] + + # Fired after the plot is redrawn. + on_redraw: EventHandler[_event_data_signature] + + # Fired after selecting plot elements. + on_selected: EventHandler[_event_points_data_signature] + + # Fired while dragging a selection. + on_selecting: EventHandler[_event_points_data_signature] + + # Fired while an animation is occuring. + on_transitioning: EventHandler[_event_data_signature] + + # Fired when a transition is stopped early. + on_transition_interrupted: EventHandler[_event_data_signature] + + # Fired when a hovered element is no longer hovered. + on_unhover: EventHandler[_event_points_data_signature] + + def add_custom_code(self) -> list[str]: + """Add custom codes for processing the plotly points data. + + Returns: + Custom code snippets for the module level. + """ + return [ + "const removeUndefined = (obj) => {Object.keys(obj).forEach(key => obj[key] === undefined && delete obj[key]); return obj}", + """ +const extractPoints = (points) => { + if (!points) return []; + return points.map(point => { + const bbox = point.bbox ? removeUndefined({ + x0: point.bbox.x0, + x1: point.bbox.x1, + y0: point.bbox.y0, + y1: point.bbox.y1, + z0: point.bbox.y0, + z1: point.bbox.y1, + }) : undefined; + return removeUndefined({ + x: point.x, + y: point.y, + z: point.z, + lat: point.lat, + lon: point.lon, + curveNumber: point.curveNumber, + pointNumber: point.pointNumber, + pointNumbers: point.pointNumbers, + pointIndex: point.pointIndex, + 'marker.color': point['marker.color'], + 'marker.size': point['marker.size'], + bbox: bbox, + }) + }) +} +""", + ] + def _render(self): tag = super()._render() figure = self.data.to(dict) diff --git a/reflex/components/plotly/plotly.pyi b/reflex/components/plotly/plotly.pyi index 02288804f..b8a57f781 100644 --- a/reflex/components/plotly/plotly.pyi +++ b/reflex/components/plotly/plotly.pyi @@ -8,7 +8,9 @@ from reflex.vars import Var, BaseVar, ComputedVar from reflex.event import EventChain, EventHandler, EventSpec from reflex.style import Style from typing import Any, Dict, List +from reflex.base import Base from reflex.components.component import NoSSRComponent +from reflex.event import EventHandler from reflex.vars import Var try: @@ -16,6 +18,11 @@ try: except ImportError: Figure = Any # type: ignore +class _ButtonClickData(Base): + menu: Any + button: Any + active: Any + class PlotlyLib(NoSSRComponent): @overload @classmethod @@ -93,6 +100,7 @@ class PlotlyLib(NoSSRComponent): ... class Plotly(PlotlyLib): + def add_custom_code(self) -> list[str]: ... @overload @classmethod def create( # type: ignore @@ -108,21 +116,48 @@ class Plotly(PlotlyLib): class_name: Optional[Any] = None, autofocus: Optional[bool] = None, custom_attrs: Optional[Dict[str, Union[Var, str]]] = None, + on_after_plot: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_animated: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_animating_frame: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_animation_interrupted: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_autosize: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_before_hover: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, on_blur: Optional[ Union[EventHandler, EventSpec, list, function, BaseVar] ] = None, + on_button_clicked: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, on_click: Optional[ Union[EventHandler, EventSpec, list, function, BaseVar] ] = None, on_context_menu: Optional[ Union[EventHandler, EventSpec, list, function, BaseVar] ] = None, + on_deselect: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, on_double_click: Optional[ Union[EventHandler, EventSpec, list, function, BaseVar] ] = None, on_focus: Optional[ Union[EventHandler, EventSpec, list, function, BaseVar] ] = None, + on_hover: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, on_mount: Optional[ Union[EventHandler, EventSpec, list, function, BaseVar] ] = None, @@ -147,9 +182,36 @@ class Plotly(PlotlyLib): on_mouse_up: Optional[ Union[EventHandler, EventSpec, list, function, BaseVar] ] = None, + on_redraw: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_relayout: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_relayouting: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_restyle: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, on_scroll: Optional[ Union[EventHandler, EventSpec, list, function, BaseVar] ] = None, + on_selected: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_selecting: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_transition_interrupted: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_transitioning: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, + on_unhover: Optional[ + Union[EventHandler, EventSpec, list, function, BaseVar] + ] = None, on_unmount: Optional[ Union[EventHandler, EventSpec, list, function, BaseVar] ] = None, diff --git a/reflex/utils/pyi_generator.py b/reflex/utils/pyi_generator.py index a9468e4fc..518cbcb38 100644 --- a/reflex/utils/pyi_generator.py +++ b/reflex/utils/pyi_generator.py @@ -320,6 +320,7 @@ def _extract_class_props_as_ast_nodes( all_props = [] kwargs = [] for target_class in clzs: + event_triggers = target_class().get_event_triggers() # Import from the target class to ensure type hints are resolvable. exec(f"from {target_class.__module__} import *", type_hint_globals) for name, value in target_class.__annotations__.items(): @@ -327,6 +328,7 @@ def _extract_class_props_as_ast_nodes( name in spec.kwonlyargs or name in EXCLUDED_PROPS or name in all_props + or name in event_triggers or (isinstance(value, str) and "ClassVar" in value) ): continue