From 888f0e8f6571ab69b095ebc02fd08d24e92e94bf Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 8 Oct 2024 11:08:30 -0400 Subject: [PATCH 1/4] remove ipywidget code from linear selector and cleanup docstrings --- fastplotlib/graphics/selectors/_linear.py | 189 ++++------------------ 1 file changed, 28 insertions(+), 161 deletions(-) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index ae3648f5e..7d526a16b 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -1,22 +1,16 @@ -from typing import * import math from numbers import Real +from typing import Sequence import numpy as np import pygfx -from ...utils.gui import IS_JUPYTER from .._base import Graphic from .._collection_base import GraphicCollection from .._features._selection_features import LinearSelectionFeature from ._base_selector import BaseSelector -if IS_JUPYTER: - # If using the jupyter backend, user has jupyter_rfb, and thus also ipywidgets - import ipywidgets - - class LinearSelector(BaseSelector): @property def parent(self) -> Graphic: @@ -39,11 +33,11 @@ def selection(self, value: int): self._selection.set_value(self, value) @property - def limits(self) -> Tuple[float, float]: + def limits(self) -> tuple[float, float]: return self._limits @limits.setter - def limits(self, values: Tuple[float, float]): + def limits(self, values: tuple[float, float]): # check that `values` is an iterable of two real numbers # using `Real` here allows it to work with builtin `int` and `float` types, and numpy scaler types if len(values) != 2 or not all(map(lambda v: isinstance(v, Real), values)): @@ -62,46 +56,50 @@ def __init__( center: float, axis: str = "x", parent: Graphic = None, - color: str | tuple = "w", + color: str | Sequence[float] | np.ndarray = "w", thickness: float = 2.5, arrow_keys_modifier: str = "Shift", name: str = None, ): """ - Create a horizontal or vertical line slider that is synced to an ipywidget IntSlider + Create a horizontal or vertical line that can be used to select a value along an axis. Parameters ---------- selection: int - initial x or y selected position for the slider, in world space + initial x or y selected position for the selector, in data space limits: (int, int) - (min, max) limits along the x or y axis for the selector, in world space + (min, max) limits along the x or y-axis for the selector, in data space - axis: str, default "x" - "x" | "y", the axis which the slider can move along + size: float + size of the selector, usually the range of the data center: float - center offset of the selector on the orthogonal axis, by default the data mean + center offset of the selector on the orthogonal axis, usually the data mean + + axis: str, default "x" + "x" | "y", the axis along which the selector can move parent: Graphic - parent graphic for this LineSelector + parent graphic for this LinearSelector arrow_keys_modifier: str modifier key that must be pressed to initiate movement using arrow keys, must be one of: - "Control", "Shift", "Alt" or ``None``. Double click on the selector first to enable the + "Control", "Shift", "Alt" or ``None``. Double-click the selector first to enable the arrow key movements, or set the attribute ``arrow_key_events_enabled = True`` thickness: float, default 2.5 - thickness of the slider + thickness of the selector - color: Any, default "w" - selection to set the color of the slider + color: str | tuple | np.ndarray, default "w" + color of the selector name: str, optional - name of line slider + name of linear selector """ + if len(limits) != 2: raise ValueError("limits must be a tuple of 2 integers, i.e. (int, int)") @@ -155,10 +153,6 @@ def __init__( self._move_info: dict = None - self._block_ipywidget_call = False - - self._handled_widgets = list() - if axis == "x": offset = (parent.offset[0], center + parent.offset[1], 0) elif axis == "y": @@ -187,149 +181,22 @@ def __init__( else: self._selection.set_value(self, selection) - # update any ipywidgets - self.add_event_handler(self._update_ipywidgets, "selection") - - def _setup_ipywidget_slider(self, widget): - # setup an ipywidget slider with bidirectional callbacks to this LinearSelector - value = self.selection - - if isinstance(widget, ipywidgets.IntSlider): - value = int(value) - - widget.value = value - - # user changes widget -> linear selection changes - widget.observe(self._ipywidget_callback, "value") - - self._handled_widgets.append(widget) - - def _update_ipywidgets(self, ev): - # update the ipywidget sliders when LinearSelector value changes - self._block_ipywidget_call = True # prevent infinite recursion - - value = ev.info["value"] - # update all the handled slider widgets - for widget in self._handled_widgets: - if isinstance(widget, ipywidgets.IntSlider): - widget.value = int(value) - else: - widget.value = value - - self._block_ipywidget_call = False - - def _ipywidget_callback(self, change): - # update the LinearSelector when the ipywidget value changes - if self._block_ipywidget_call or self._moving: - return - - self.selection = change["new"] - - def _fpl_add_plot_area_hook(self, plot_area): - super()._fpl_add_plot_area_hook(plot_area=plot_area) - - # resize the slider widgets when the canvas is resized - self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize") - - def _set_slider_layout(self, *args): - w, h = self._plot_area.renderer.logical_size - - for widget in self._handled_widgets: - widget.layout = ipywidgets.Layout(width=f"{w}px") - - def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): + def get_selected_index(self, graphic: Graphic = None) -> int | list[int]: """ - Makes and returns an ipywidget slider that is associated to this LinearSelector - - Parameters - ---------- - kind: str - "IntSlider", "FloatSlider" or "FloatLogSlider" + Data index the selector is currently at w.r.t. the Graphic data. - kwargs - passed to the ipywidget slider constructor - - Returns - ------- - ipywidgets.Intslider or ipywidgets.FloatSlider - - """ - - if not IS_JUPYTER: - raise ImportError( - "Must installed `ipywidgets` to use `make_ipywidget_slider()`" - ) - - if kind not in ["IntSlider", "FloatSlider", "FloatLogSlider"]: - raise TypeError( - f"`kind` must be one of: 'IntSlider', 'FloatSlider' or 'FloatLogSlider'\n" - f"You have passed: '{kind}'" - ) - - cls = getattr(ipywidgets, kind) - - value = self.selection - if "Int" in kind: - value = int(self.selection) - - slider = cls( - min=self.limits[0], - max=self.limits[1], - value=value, - **kwargs, - ) - self.add_ipywidget_handler(slider) - - return slider - - def add_ipywidget_handler(self, widget, step: Union[int, float] = None): - """ - Bidirectionally connect events with a ipywidget slider - - Parameters - ---------- - widget: ipywidgets.IntSlider, ipywidgets.FloatSlider, or ipywidgets.FloatLogSlider - ipywidget slider to connect to - - step: int or float, default ``None`` - step size, if ``None`` 100 steps are created - - """ - - if not isinstance( - widget, - (ipywidgets.IntSlider, ipywidgets.FloatSlider, ipywidgets.FloatLogSlider), - ): - raise TypeError( - f"`widget` must be one of: ipywidgets.IntSlider, ipywidgets.FloatSlider, or ipywidgets.FloatLogSlider\n" - f"You have passed a: <{type(widget)}" - ) - - if step is None: - step = (self.limits[1] - self.limits[0]) / 100 - - if isinstance(widget, ipywidgets.IntSlider): - step = int(step) - - widget.step = step - - self._setup_ipywidget_slider(widget) - - def get_selected_index(self, graphic: Graphic = None) -> Union[int, List[int]]: - """ - Data index the slider is currently at w.r.t. the Graphic data. With LineGraphic data, the geometry x or y - position is not always the data position, for example if plotting data using np.linspace. Use this to get - the data index of the slider. + With LineGraphic data, the geometry x or y position is not always the data position, for example if plotting + data using np.linspace. Use this to get the data index of the selector. Parameters ---------- graphic: Graphic, optional - Graphic to get the selected data index from. Default is the parent graphic associated to the slider. + Graphic to get the selected data index from. Default is the parent graphic associated to the selector. Returns ------- int or List[int] - data index the slider is currently at, list of ``int`` if a Collection + data index the selector is currently at, list of ``int`` if a Collection """ source = self._get_source(graphic) @@ -354,10 +221,10 @@ def _get_selected_index(self, graphic): "Line" in graphic.__class__.__name__ or "Scatter" in graphic.__class__.__name__ ): - # we want to find the index of the data closest to the slider position + # we want to find the index of the data closest to the selector position find_value = self.selection - # get closest data index to the world space position of the slider + # get closest data index to the world space position of the selector idx = np.searchsorted(data, find_value, side="left") if idx > 0 and ( From 01d2be64777010ea64f32f0ae52315494655c9f3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 8 Oct 2024 11:21:53 -0400 Subject: [PATCH 2/4] remove ipywidget code from LinearRegionSelector, cleanup docstrings --- .../graphics/selectors/_linear_region.py | 180 +++--------------- 1 file changed, 23 insertions(+), 157 deletions(-) diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index f83385d76..db7788d00 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -1,21 +1,15 @@ -from typing import * from numbers import Real +from typing import Sequence import numpy as np import pygfx -from ...utils.gui import IS_JUPYTER from .._base import Graphic from .._collection_base import GraphicCollection from .._features._selection_features import LinearRegionSelectionFeature from ._base_selector import BaseSelector -if IS_JUPYTER: - # If using the jupyter backend, user has jupyter_rfb, and thus also ipywidgets - import ipywidgets - - class LinearRegionSelector(BaseSelector): @property def parent(self) -> Graphic | None: @@ -23,35 +17,31 @@ def parent(self) -> Graphic | None: return self._parent @property - def selection(self) -> Sequence[float] | List[Sequence[float]]: + def selection(self) -> np.ndarray[float]: """ - (min, max) of data value along selector's axis + (min, max) of selector along selector's axis """ # TODO: This probably does not account for rotation since world.position # does not account for rotation, we can do this later return self._selection.value.copy() - # TODO: if no parent graphic is set, this just returns world positions + # TODO: if no parent graphic is set, this just returns values in world space # but should we change it? - # return self._selection.value @selection.setter def selection(self, selection: Sequence[float]): - # set (xmin, xmax), or (ymin, ymax) of the selector in data space + # set (min, max) of the selector in data space graphic = self._parent - if isinstance(graphic, GraphicCollection): - pass - self._selection.set_value(self, selection) @property - def limits(self) -> Tuple[float, float]: + def limits(self) -> tuple[float, float]: return self._limits @limits.setter - def limits(self, values: Tuple[float, float]): + def limits(self, values: tuple[float, float]): # check that `values` is an iterable of two real numbers # using `Real` here allows it to work with builtin `int` and `float` types, and numpy scaler types if len(values) != 2 or not all(map(lambda v: isinstance(v, Real), values)): @@ -70,8 +60,8 @@ def __init__( axis: str = "x", parent: Graphic = None, resizable: bool = True, - fill_color=(0, 0, 0.35), - edge_color=(0.8, 0.6, 0), + fill_color: str | Sequence[float] = (0, 0, 0.35), + edge_color: str | Sequence[float] = (0.8, 0.6, 0), edge_thickness: float = 8, arrow_keys_modifier: str = "Shift", name: str = None, @@ -82,7 +72,7 @@ def __init__( Assumes that the data under the selector is a function of the axis on which the selector moves along. Example: if the selector is along the x-axis, then there must be only one y-value for each - x-value, otherwise functions such as ``get_selected_data()`` do not make sense. + x-value, otherwise methods such as ``get_selected_data()`` do not make sense. Parameters ---------- @@ -93,19 +83,19 @@ def __init__( (min limit, max limit) within which the selector can move size: int - height or width of the selector + usually the data range, height or width of the selector center: float - center offset of the selector on the orthogonal axis, by default the data mean + usually the data mean, center offset of the selector on the orthogonal axis axis: str, default "x" - "x" | "y", axis the selected can move on + "x" | "y", axis along which the selector can move - parent: Graphic, default ``None`` - associate this selector with a parent Graphic from which to fetch data or indices + parent: ``Graphic`` instance, default ``None`` + associate this selector with a parent ``Graphic`` from which to fetch data or indices resizable: bool - if ``True``, the edges can be dragged to resize the width of the linear selection + if ``True``, the edges can be dragged to change the range of the selection fill_color: str, array, or tuple fill color for the selector, passed to pygfx.Color @@ -120,8 +110,8 @@ def __init__( modifier key that must be pressed to initiate movement using arrow keys, must be one of: "Control", "Shift", "Alt" or ``None`` - name: str - name for this selector graphic + name: str, optional + name of this selector graphic """ @@ -201,14 +191,14 @@ def __init__( ), ) - self.edges: Tuple[pygfx.Line, pygfx.Line] = (line0, line1) + self.edges: tuple[pygfx.Line, pygfx.Line] = (line0, line1) # add the edge lines for edge in self.edges: edge.world.z = -0.5 group.add(edge) - # TODO: if parent offset changes, we should set the selector offset too + # TODO: if parent offset changes, we should set the selector offset too, use offset evented property # TODO: add check if parent is `None`, will throw error otherwise if axis == "x": offset = (parent.offset[0], center + parent.offset[1], 0) @@ -222,8 +212,6 @@ def __init__( selection, axis=axis, limits=self._limits ) - self._handled_widgets = list() - self._block_ipywidget_call = False self._pygfx_event = None BaseSelector.__init__( @@ -244,7 +232,7 @@ def __init__( def get_selected_data( self, graphic: Graphic = None - ) -> Union[np.ndarray, List[np.ndarray]]: + ) -> np.ndarray | list[np.ndarray]: """ Get the ``Graphic`` data bounded by the current selection. Returns a view of the data array. @@ -277,7 +265,7 @@ def get_selected_data( # this will return a list of views of the arrays, therefore no copy operations occur # it's fine and fast even as a list of views because there is no re-allocating of memory # this is fast even for slicing a 10,000 x 5,000 LineStack - data_selections: List[np.ndarray] = list() + data_selections: list[np.ndarray] = list() for i, g in enumerate(source.graphics): if ixs[i].size == 0: @@ -317,7 +305,7 @@ def get_selected_data( def get_selected_indices( self, graphic: Graphic = None - ) -> Union[np.ndarray, List[np.ndarray]]: + ) -> np.ndarray | list[np.ndarray]: """ Returns the indices of the ``Graphic`` data bounded by the current selection. @@ -371,128 +359,6 @@ def get_selected_indices( # indices map directly to grid geometry for image data buffer return np.arange(*bounds, dtype=int) - def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs): - """ - Makes and returns an ipywidget slider that is associated to this LinearSelector - - Parameters - ---------- - kind: str - "IntRangeSlider" or "FloatRangeSlider" - - kwargs - passed to the ipywidget slider constructor - - Returns - ------- - ipywidgets.Intslider or ipywidgets.FloatSlider - - """ - - if not IS_JUPYTER: - raise ImportError( - "Must installed `ipywidgets` to use `make_ipywidget_slider()`" - ) - - if kind not in ["IntRangeSlider", "FloatRangeSlider"]: - raise TypeError( - f"`kind` must be one of: 'IntRangeSlider', or 'FloatRangeSlider'\n" - f"You have passed: '{kind}'" - ) - - cls = getattr(ipywidgets, kind) - - value = self.selection - if "Int" in kind: - value = tuple(map(int, self.selection)) - - slider = cls( - min=self.limits[0], - max=self.limits[1], - value=value, - **kwargs, - ) - self.add_ipywidget_handler(slider) - - return slider - - def add_ipywidget_handler(self, widget, step: Union[int, float] = None): - """ - Bidirectionally connect events with a ipywidget slider - - Parameters - ---------- - widget: ipywidgets.IntRangeSlider or ipywidgets.FloatRangeSlider - ipywidget slider to connect to - - step: int or float, default ``None`` - step size, if ``None`` 100 steps are created - - """ - if not isinstance( - widget, (ipywidgets.IntRangeSlider, ipywidgets.FloatRangeSlider) - ): - raise TypeError( - f"`widget` must be one of: ipywidgets.IntRangeSlider or ipywidgets.FloatRangeSlider\n" - f"You have passed a: <{type(widget)}" - ) - - if step is None: - step = (self.limits[1] - self.limits[0]) / 100 - - if isinstance(widget, ipywidgets.IntSlider): - step = int(step) - - widget.step = step - - self._setup_ipywidget_slider(widget) - - def _setup_ipywidget_slider(self, widget): - # setup an ipywidget slider with bidirectional callbacks to this LinearSelector - value = self.selection - - if isinstance(widget, ipywidgets.IntSlider): - value = tuple(map(int, value)) - - widget.value = value - - # user changes widget -> linear selection changes - widget.observe(self._ipywidget_callback, "value") - - # user changes linear selection -> widget changes - self.add_event_handler(self._update_ipywidgets, "selection") - - self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize") - - self._handled_widgets.append(widget) - - def _update_ipywidgets(self, ev): - # update the ipywidget sliders when LinearSelector value changes - self._block_ipywidget_call = True # prevent infinite recursion - - value = ev.pick_info["new_data"] - # update all the handled slider widgets - for widget in self._handled_widgets: - if isinstance(widget, ipywidgets.IntSlider): - widget.value = tuple(map(int, value)) - else: - widget.value = value - - self._block_ipywidget_call = False - - def _ipywidget_callback(self, change): - # update the LinearSelector if the ipywidget value changes - if self._block_ipywidget_call or self._moving: - return - - self.selection = change["new"] - - def _set_slider_layout(self, *args): - w, h = self._plot_area.renderer.logical_size - - for widget in self._handled_widgets: - widget.layout = ipywidgets.Layout(width=f"{w}px") - def _move_graphic(self, delta: np.ndarray): # add delta to current min, max to get new positions if self.axis == "x": From 9e416fb5a9d02914380f6cbf782d8633028aa92b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 8 Oct 2024 11:42:21 -0400 Subject: [PATCH 3/4] remove a method --- fastplotlib/graphics/selectors/_linear.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 7d526a16b..dfe7aadab 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -265,9 +265,3 @@ def _move_graphic(self, delta: np.ndarray): self.selection = self.selection + delta[0] else: self.selection = self.selection + delta[1] - - def _fpl_prepare_del(self): - for widget in self._handled_widgets: - widget.unobserve(self._ipywidget_callback, "value") - - super()._fpl_prepare_del() From 46602572aeea22d78230df291dceffd125f6becd Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 14 Oct 2024 04:07:45 -0400 Subject: [PATCH 4/4] update API docs --- docs/source/api/selectors/LinearRegionSelector.rst | 2 -- docs/source/api/selectors/LinearSelector.rst | 2 -- 2 files changed, 4 deletions(-) diff --git a/docs/source/api/selectors/LinearRegionSelector.rst b/docs/source/api/selectors/LinearRegionSelector.rst index bb406b7e2..9637dd8e1 100644 --- a/docs/source/api/selectors/LinearRegionSelector.rst +++ b/docs/source/api/selectors/LinearRegionSelector.rst @@ -43,12 +43,10 @@ Methods LinearRegionSelector.add_axes LinearRegionSelector.add_event_handler - LinearRegionSelector.add_ipywidget_handler LinearRegionSelector.clear_event_handlers LinearRegionSelector.get_selected_data LinearRegionSelector.get_selected_index LinearRegionSelector.get_selected_indices - LinearRegionSelector.make_ipywidget_slider LinearRegionSelector.remove_event_handler LinearRegionSelector.rotate LinearRegionSelector.share_property diff --git a/docs/source/api/selectors/LinearSelector.rst b/docs/source/api/selectors/LinearSelector.rst index d434ef82f..c514f982c 100644 --- a/docs/source/api/selectors/LinearSelector.rst +++ b/docs/source/api/selectors/LinearSelector.rst @@ -43,12 +43,10 @@ Methods LinearSelector.add_axes LinearSelector.add_event_handler - LinearSelector.add_ipywidget_handler LinearSelector.clear_event_handlers LinearSelector.get_selected_data LinearSelector.get_selected_index LinearSelector.get_selected_indices - LinearSelector.make_ipywidget_slider LinearSelector.remove_event_handler LinearSelector.rotate LinearSelector.share_property