diff --git a/docs/source/api/graphics.rst b/docs/source/api/graphics.rst index c3e264a50..d38045dae 100644 --- a/docs/source/api/graphics.rst +++ b/docs/source/api/graphics.rst @@ -30,18 +30,11 @@ Line Stack .. autoclass:: fastplotlib.graphics.line_collection.LineStack :members: :inherited-members: - -Line Slider -########### - -.. autoclass:: fastplotlib.graphics.line_slider.LineSlider - :members: - :inherited-members: Heatmap ####### -.. autoclass:: fastplotlib.graphics.heatmap.HeatmapGraphic +.. autoclass:: fastplotlib.graphics.image.HeatmapGraphic :members: :inherited-members: diff --git a/docs/source/api/selectors.rst b/docs/source/api/selectors.rst new file mode 100644 index 000000000..c43f936bd --- /dev/null +++ b/docs/source/api/selectors.rst @@ -0,0 +1,15 @@ +.. _api_selectors: + +Selectors +********* + +Linear +###### + +.. autoclass:: fastplotlib.graphics.selectors.LinearSelector + :members: + :inherited-members: + +.. autoclass:: fastplotlib.graphics.selectors.LinearRegionSelector + :members: + :inherited-members: diff --git a/docs/source/index.rst b/docs/source/index.rst index b7f823f07..8ccc160b5 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -14,6 +14,7 @@ Welcome to fastplotlib's documentation! Subplot Gridplot Graphics + Selectors Widgets Summary diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 66ad5820f..5a4786ca2 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -2,7 +2,6 @@ from .line import LineGraphic from .scatter import ScatterGraphic from .image import ImageGraphic, HeatmapGraphic -# from .heatmap import HeatmapGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack diff --git a/fastplotlib/graphics/features/_data.py b/fastplotlib/graphics/features/_data.py index 5063b4200..b46db6e49 100644 --- a/fastplotlib/graphics/features/_data.py +++ b/fastplotlib/graphics/features/_data.py @@ -155,15 +155,11 @@ def buffer(self) -> List[Texture]: """list of Texture buffer for the image data""" return [img.geometry.grid for img in self._parent.world_object.children] - def update_gpu(self): - """Update the GPU with the buffer""" - self._update_range(None) - def __getitem__(self, item): return self._data[item] def __call__(self, *args, **kwargs): - return self.buffer.data + return self._data def __setitem__(self, key, value): # make sure supported type, not float64 etc. diff --git a/fastplotlib/graphics/heatmap.py b/fastplotlib/graphics/heatmap.py deleted file mode 100644 index 42ad67c73..000000000 --- a/fastplotlib/graphics/heatmap.py +++ /dev/null @@ -1,139 +0,0 @@ -import numpy as np -import pygfx -from typing import * -from .image import ImageGraphic - -from ..utils import quick_min_max, get_cmap_texture - - -default_selection_options = { - "mode": "single", - "orientation": "row", - "callbacks": None, -} - - -class SelectionOptions: - def __init__( - self, - event: str = "double_click", # click or double_click - event_button: Union[int, str] = 1, - mode: str = "single", - axis: str = "row", - color: Tuple[int, int, int, int] = None, - callbacks: List[callable] = None, - ): - self.event = event - self.event_button = event_button - self.mode = mode - self.axis = axis - - if color is not None: - self.color = color - - else: - self.color = (1, 1, 1, 0.4) - - if callbacks is None: - self.callbacks = list() - else: - self.callbacks = callbacks - - -class HeatmapGraphic(ImageGraphic): - def __init__( - self, - data: np.ndarray, - vmin: int = None, - vmax: int = None, - cmap: str = 'plasma', - selection_options: dict = None, - *args, - **kwargs - ): - """ - Create a Heatmap Graphic - - Parameters - ---------- - data: array-like, must be 2-dimensional - | array-like, usually numpy.ndarray, must support ``memoryview()`` - | Tensorflow Tensors also work **probably**, but not thoroughly tested - vmin: int, optional - minimum value for color scaling, calculated from data if not provided - vmax: int, optional - maximum value for color scaling, calculated from data if not provided - cmap: str, optional - colormap to use to display the image data, default is ``"plasma"`` - selection_options - args: - additional arguments passed to Graphic - kwargs: - additional keyword arguments passed to Graphic - - """ - super().__init__(data, vmin, vmax, cmap, *args, **kwargs) - - self.selection_options = SelectionOptions() - self.selection_options.callbacks = list() - - if selection_options is not None: - for k in selection_options.keys(): - setattr(self.selection_options, k, selection_options[k]) - - self.world_object.add_event_handler( - self.handle_selection_event, self.selection_options.event - ) - - self._highlights = list() - - def handle_selection_event(self, event): - if not event.button == self.selection_options.event_button: - return - - if self.selection_options.mode == "single": - for h in self._highlights: - self.remove_highlight(h) - - rval = self.add_highlight(event) - - for f in self.selection_options.callbacks: - f(rval) - - def remove_highlight(self, h): - self._highlights.remove(h) - self.world_object.remove(h) - - def add_highlight(self, event): - index = event.pick_info["index"] - - if self.selection_options.axis == "row": - index = index[1] - w = self.data.shape[1] - h = 1 - - pos = ((self.data.shape[1] / 2) - 0.5, index, 1) - rval = self.data[index, :] # returned to selection.callbacks functions - - elif self.selection_options.axis == "column": - index = index[0] - w = 1 - h = self.data.shape[0] - - pos = (index, (self.data.shape[0] / 2) - 0.5, 1) - rval = self.data[:, index] - - geometry = pygfx.plane_geometry( - width=w, - height=h - ) - - material = pygfx.MeshBasicMaterial(color=self.selection_options.color) - - self.selection_graphic = pygfx.Mesh(geometry, material) - self.selection_graphic.position.set(*pos) - - self.world_object.add(self.selection_graphic) - self._highlights.append(self.selection_graphic) - - return rval \ No newline at end of file diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 835061328..98f2fb3ee 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -1,17 +1,162 @@ from typing import * from math import ceil from itertools import product +import weakref import numpy as np import pygfx from ._base import Graphic, Interaction, PreviouslyModifiedData +from .selectors import LinearSelector, LinearRegionSelector from .features import ImageCmapFeature, ImageDataFeature, HeatmapDataFeature, HeatmapCmapFeature from .features._base import to_gpu_supported_dtype from ..utils import quick_min_max -class ImageGraphic(Graphic, Interaction): +class _ImageHeatmapSelectorsMixin: + def add_linear_selector(self, selection: int = None, padding: float = 50, **kwargs) -> LinearSelector: + """ + Adds a linear selector. + + Parameters + ---------- + selection: int + initial position of the selector + + padding: float + pad the length of the selector + + kwargs + passed to :class:`.LinearSelector` + + Returns + ------- + LinearSelector + + """ + + bounds_init, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) + + if selection is None: + selection = limits[0] + + if selection < limits[0] or selection > limits[1]: + raise ValueError(f"the passed selection: {selection} is beyond the limits: {limits}") + + selector = LinearSelector( + selection=selection, + limits=limits, + end_points=end_points, + parent=weakref.proxy(self), + **kwargs + ) + + self._plot_area.add_graphic(selector, center=False) + selector.position.z = self.position.z + 1 + + return weakref.proxy(selector) + + def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> LinearRegionSelector: + """ + Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + padding: float, default 100.0 + Extends the linear selector along the y-axis to make it easier to interact with. + + kwargs + passed to ``LinearRegionSelector`` + + Returns + ------- + LinearRegionSelector + linear selection graphic + + """ + + bounds_init, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) + + # create selector + selector = LinearRegionSelector( + bounds=bounds_init, + limits=limits, + size=size, + origin=origin, + parent=weakref.proxy(self), + **kwargs + ) + + self._plot_area.add_graphic(selector, center=False) + # so that it is above this graphic + selector.position.set_z(self.position.z + 3) + selector.fill.material.color = (*selector.fill.material.color[:-1], 0.2) + + # PlotArea manages this for garbage collection etc. just like all other Graphics + # so we should only work with a proxy on the user-end + return weakref.proxy(selector) + + # TODO: this method is a bit of a mess, can refactor later + def _get_linear_selector_init_args(self, padding: float, **kwargs): + # computes initial bounds, limits, size and origin of linear selectors + data = self.data() + + if "axis" in kwargs.keys(): + axis = kwargs["axis"] + else: + axis = "x" + + if axis == "x": + offset = self.position.x + # x limits, number of columns + limits = (offset, data.shape[1]) + + # size is number of rows + padding + # used by LinearRegionSelector but not LinearSelector + size = data.shape[0] + padding + + # initial position of the selector + # center row + position_y = data.shape[0] / 2 + + # need y offset too for this + origin = (limits[0] - offset, position_y + self.position.y) + + # endpoints of the data range + # used by linear selector but not linear region + # padding, n_rows + padding + end_points = (0 - padding, data.shape[0] + padding) + else: + offset = self.position.y + # y limits + limits = (offset, data.shape[0]) + + # width + padding + # used by LinearRegionSelector but not LinearSelector + size = data.shape[1] + padding + + # initial position of the selector + position_x = data.shape[1] / 2 + + # need x offset too for this + origin = (position_x + self.position.x, limits[0] - offset) + + # endpoints of the data range + # used by linear selector but not linear region + end_points = (0 - padding, data.shape[1] + padding) + + # initial bounds are 20% of the limits range + # used by LinearRegionSelector but not LinearSelector + bounds_init = (limits[0], int(np.ptp(limits) * 0.2) + offset) + + return bounds_init, limits, size, origin, axis, end_points + + def _add_plot_area_hook(self, plot_area): + self._plot_area = plot_area + + +class ImageGraphic(Graphic, Interaction, _ImageHeatmapSelectorsMixin): feature_events = ( "data", "cmap", @@ -178,7 +323,7 @@ def col_chunk_index(self, index: int): self._col_chunk_index = index -class HeatmapGraphic(Graphic, Interaction): +class HeatmapGraphic(Graphic, Interaction, _ImageHeatmapSelectorsMixin): feature_events = ( "data", "cmap", diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py new file mode 100644 index 000000000..84c72283d --- /dev/null +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -0,0 +1,293 @@ +from typing import * +from dataclasses import dataclass +from functools import partial + +from pygfx.linalg import Vector3 +from pygfx import WorldObject, Line, Mesh, Points + + +@dataclass +class MoveInfo: + """ + stores move info for a WorldObject + """ + + # last position for an edge, fill, or vertex in world coordinates + # can be None, such as key events + last_position: Vector3 | None + + # WorldObject or "key" event + source: WorldObject | str + + +# key bindings used to move the selector +key_bind_direction = { + "ArrowRight": Vector3(1, 0, 0), + "ArrowLeft": Vector3(-1, 0, 0), + "ArrowUp": Vector3(0, 1, 0), + "ArrowDown": Vector3(0, -1, 0), +} + + +# Selector base class +class BaseSelector: + def __init__( + self, + edges: Tuple[Line, ...] = None, + fill: Tuple[Mesh, ...] = None, + vertices: Tuple[Points, ...] = None, + hover_responsive: Tuple[WorldObject, ...] = None, + arrow_keys_modifier: str = None, + axis: str = None + ): + if edges is None: + edges = tuple() + + if fill is None: + fill = tuple() + + if vertices is None: + vertices = tuple() + + self._edges: Tuple[Line, ...] = edges + self._fill: Tuple[Mesh, ...] = fill + self._vertices: Tuple[Points, ...] = vertices + + self._world_objects: Tuple[WorldObject, ...] = self._edges + self._fill + self._vertices + + self._hover_responsive: Tuple[WorldObject, ...] = hover_responsive + + if hover_responsive is not None: + self._original_colors = dict() + for wo in self._hover_responsive: + self._original_colors[wo] = wo.material.color + + self.axis = axis + + # current delta in world coordinates + self.delta: Vector3 = None + + self.arrow_keys_modifier = arrow_keys_modifier + # if not False, moves the slider on every render cycle + self._key_move_value = False + self.step: float = 1.0 #: step size for moving selector using the arrow keys + + self._move_info: MoveInfo = None + + def get_selected_index(self): + raise NotImplementedError + + def get_selected_indices(self): + raise NotImplementedError + + def get_selected_data(self): + raise NotImplementedError + + def _get_source(self, graphic): + if self.parent is None and graphic is None: + raise AttributeError( + "No Graphic to apply selector. " + "You must either set a ``parent`` Graphic on the selector, or pass a graphic." + ) + + # use passed graphic if provided, else use parent + if graphic is not None: + source = graphic + else: + source = self.parent + + return source + + def _add_plot_area_hook(self, plot_area): + self._plot_area = plot_area + + # when the pointer is pressed on a fill, edge or vertex + for wo in self._world_objects: + pfunc_down = partial(self._move_start, wo) + wo.add_event_handler(pfunc_down, "pointer_down") + + # when the pointer moves + self._plot_area.renderer.add_event_handler(self._move, "pointer_move") + + # when the pointer is released + self._plot_area.renderer.add_event_handler(self._move_end, "pointer_up") + + # move directly to location of center mouse button click + self._plot_area.renderer.add_event_handler(self._move_to_pointer, "click") + + # mouse hover color events + for wo in self._hover_responsive: + wo.add_event_handler(self._pointer_enter, "pointer_enter") + wo.add_event_handler(self._pointer_leave, "pointer_leave") + + # arrow key bindings + self._plot_area.renderer.add_event_handler(self._key_down, "key_down") + self._plot_area.renderer.add_event_handler(self._key_up, "key_up") + self._plot_area.add_animations(self._key_hold) + + def _move_start(self, event_source: WorldObject, ev): + """ + Called on "pointer_down" events + + Parameters + ---------- + event_source: WorldObject + event source, for example selection fill area ``Mesh`` an edge ``Line`` or vertex ``Points`` + + ev: Event + pygfx ``Event`` + + """ + last_position = self._plot_area.map_screen_to_world((ev.x, ev.y)) + + self._move_info = MoveInfo( + last_position=last_position, + source=event_source + ) + + def _move(self, ev): + """ + Called on pointer move events + + Parameters + ---------- + ev + + Returns + ------- + + """ + if self._move_info is None: + return + + # disable controller during moves + self._plot_area.controller.enabled = False + + # get pointer current world position + pointer_pos_screen = (ev.x, ev.y) + world_pos = self._plot_area.map_screen_to_world(pointer_pos_screen) + + # outside this viewport + if world_pos is None: + return + + # compute the delta + self.delta = world_pos.clone().sub(self._move_info.last_position) + self._pygfx_event = ev + + self._move_graphic(self.delta, ev) + + # update last position + self._move_info.last_position = world_pos + + self._plot_area.controller.enabled = True + + def _move_graphic(self, delta, ev): + raise NotImplementedError("Must be implemented in subclass") + + def _move_end(self, ev): + self._move_info = None + self._plot_area.controller.enabled = True + + def _move_to_pointer(self, ev): + """ + Calculates delta just using current world object position and calls self._move_graphic(). + """ + current_position = self.world_object.position.clone() + + # middle mouse button clicks + if ev.button != 3: + return + + click_pos = (ev.x, ev.y) + world_pos = self._plot_area.map_screen_to_world(click_pos) + + # outside this viewport + if world_pos is None: + return + + self.delta = world_pos.clone().sub(current_position) + self._pygfx_event = ev + + # use fill by default as the source + if len(self._fill) > 0: + self._move_info = MoveInfo(last_position=current_position, source=self._fill[0]) + # else use an edge + else: + self._move_info = MoveInfo(last_position=current_position, source=self._edges[0]) + + self._move_graphic(self.delta, ev) + self._move_info = None + + def _pointer_enter(self, ev): + if self._hover_responsive is None: + return + + wo = ev.pick_info["world_object"] + if wo not in self._hover_responsive: + return + + wo.material.color = "magenta" + + def _pointer_leave(self, ev): + if self._hover_responsive is None: + return + + # reset colors + for wo in self._hover_responsive: + wo.material.color = self._original_colors[wo] + + def _key_hold(self): + if self._key_move_value: + # direction vector * step + delta = key_bind_direction[self._key_move_value].clone().multiply_scalar(self.step) + + # set event source + # use fill by default as the source + if len(self._fill) > 0: + self._move_info = MoveInfo(last_position=None, source=self._fill[0]) + # else use an edge + else: + self._move_info = MoveInfo(last_position=None, source=self._edges[0]) + + # move the graphic + self._move_graphic(delta=delta, ev=None) + + self._move_info = None + + def _key_down(self, ev): + # key bind modifier must be set and must be used for the event + # for example. if "Shift" is set as a modifier, then "Shift" must be used as a modifier during this event + if self.arrow_keys_modifier is not None and self.arrow_keys_modifier not in ev.modifiers: + return + + # ignore if non-arrow key is pressed + if ev.key not in key_bind_direction.keys(): + return + + # print(ev.key) + + self._key_move_value = ev.key + + def _key_up(self, ev): + # if arrow key is released, stop moving + if ev.key in key_bind_direction.keys(): + self._key_move_value = False + + self._move_info = None + + def __del__(self): + # clear wo event handlers + for wo in self._world_objects: + wo._event_handlers.clear() + + # remove renderer event handlers + self._plot_area.renderer.remove_event_handler(self._move, "pointer_move") + self._plot_area.renderer.remove_event_handler(self._move_end, "pointer_up") + self._plot_area.renderer.remove_event_handler(self._move_to_pointer, "click") + + self._plot_area.renderer.remove_event_handler(self._key_down, "key_down") + self._plot_area.renderer.remove_event_handler(self._key_up, "key_up") + + # remove animation func + self._plot_area.remove_animation(self._key_hold) \ No newline at end of file diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index ffdcab662..b02233135 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -14,15 +14,7 @@ from .._base import Graphic, GraphicFeature, GraphicCollection from ..features._base import FeatureEvent - - -# key bindings used to move the slider -key_bind_direction = { - "ArrowRight": 1, - "ArrowLeft": -1, - "ArrowUp": 1, - "ArrowDown": -1, -} +from ._base_selector import BaseSelector class LinearSelectionFeature(GraphicFeature): @@ -42,12 +34,16 @@ class LinearSelectionFeature(GraphicFeature): ================== ================================================================ """ - def __init__(self, parent, axis: str, value: float): + def __init__(self, parent, axis: str, value: float, limits: Tuple[int, int]): super(LinearSelectionFeature, self).__init__(parent, data=value) self.axis = axis + self.limits = limits def _set(self, value: float): + if not (self.limits[0] <= value <= self.limits[1]): + return + if self.axis == "x": self._parent.position.x = value else: @@ -85,8 +81,8 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): self._call_event_handlers(event_data) -class LinearSelector(Graphic): - feature_events = ("selection") +class LinearSelector(Graphic, BaseSelector): + feature_events = ("selection",) # TODO: make `selection` arg in graphics data space not world space def __init__( @@ -111,7 +107,7 @@ def __init__( initial x or y selected position for the slider, in world space limits: (int, int) - (min, max) limits along the x or y axis for the selector + (min, max) limits along the x or y axis for the selector, in world space axis: str, default "x" "x" | "y", the axis which the slider can move along @@ -147,8 +143,10 @@ def __init__( called when the LinearSelector selection changes. See feaure class for event pick_info table """ + if len(limits) != 2: + raise ValueError("limits must be a tuple of 2 integers, i.e. (int, int)") - self.limits = tuple(map(round, limits)) + limits = tuple(map(round, limits)) selection = round(selection) if axis == "x": @@ -168,27 +166,26 @@ def __init__( line_data = line_data.astype(np.float32) - self.axis = axis - - super(LinearSelector, self).__init__(name=name) + # super(LinearSelector, self).__init__(name=name) + # init Graphic + Graphic.__init__(self, name=name) if thickness < 1.1: material = pygfx.LineThinMaterial else: material = pygfx.LineMaterial - colors_inner = np.repeat([pygfx.Color(color)], 2, axis=0).astype(np.float32) - self.colors_outer = np.repeat([pygfx.Color([0.3, 0.3, 0.3, 1.0])], 2, axis=0).astype(np.float32) + self.colors_outer = pygfx.Color([0.3, 0.3, 0.3, 1.0]) line_inner = pygfx.Line( # self.data.feature_data because data is a Buffer - geometry=pygfx.Geometry(positions=line_data, colors=colors_inner), - material=material(thickness=thickness, vertex_colors=True) + geometry=pygfx.Geometry(positions=line_data), + material=material(thickness=thickness, color=color) ) self.line_outer = pygfx.Line( - geometry=pygfx.Geometry(positions=line_data, colors=self.colors_outer.copy()), - material=material(thickness=thickness + 6, vertex_colors=True) + geometry=pygfx.Geometry(positions=line_data), + material=material(thickness=thickness + 6, color=self.colors_outer) ) line_inner.position.z = self.line_outer.position.z + 1 @@ -206,7 +203,7 @@ def __init__( else: self.position.y = selection - self.selection = LinearSelectionFeature(self, axis=axis, value=selection) + self.selection = LinearSelectionFeature(self, axis=axis, value=selection, limits=limits) self.ipywidget_slider = ipywidget_slider @@ -219,13 +216,17 @@ def __init__( self.parent = parent - # if not False, moves the slider on every render cycle - self._key_move_value = False - self.step: float = 1.0 #: step size for moving selector using the arrow keys - self.key_bind_modifier = arrow_keys_modifier - self._block_ipywidget_call = False + # init base selector + BaseSelector.__init__( + self, + edges=(line_inner, self.line_outer), + hover_responsive=(line_inner, self.line_outer), + arrow_keys_modifier=arrow_keys_modifier, + axis=axis, + ) + def _setup_ipywidget_slider(self, widget): # setup ipywidget slider with callbacks to this LinearSelector widget.value = int(self.selection()) @@ -277,8 +278,8 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): cls = getattr(ipywidgets, kind) slider = cls( - min=self.limits[0], - max=self.limits[1], + min=self.selection.limits[0], + max=self.selection.limits[1], value=int(self.selection()), step=1, **kwargs @@ -290,7 +291,9 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): 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. + 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. Parameters ---------- @@ -303,191 +306,55 @@ def get_selected_index(self, graphic: Graphic = None) -> Union[int, List[int]]: data index the slider is currently at, list of ``int`` if a Collection """ - graphic = self._get_source(graphic) + source = self._get_source(graphic) - if isinstance(graphic, GraphicCollection): + if isinstance(source, GraphicCollection): ixs = list() - for g in graphic.graphics: + for g in source.graphics: ixs.append(self._get_selected_index(g)) return ixs - return self._get_selected_index(graphic) + return self._get_selected_index(source) def _get_selected_index(self, graphic): # the array to search for the closest value along that axis if self.axis == "x": - to_search = graphic.data()[:, 0] + geo_positions = graphic.data()[:, 0] offset = getattr(graphic.position, self.axis) else: - to_search = graphic.data()[:, 1] + geo_positions = graphic.data()[:, 1] offset = getattr(graphic.position, self.axis) - find_value = self.selection() - offset - - # get closest data index to the world space position of the slider - idx = np.searchsorted(to_search, find_value, side="left") - - if idx > 0 and (idx == len(to_search) or math.fabs(find_value - to_search[idx - 1]) < math.fabs(find_value - to_search[idx])): - return int(idx - 1) - else: - return int(idx) - - def _get_source(self, graphic): - if self.parent is None and graphic is None: - raise AttributeError( - "No Graphic to apply selector. " - "You must either set a ``parent`` Graphic on the selector, or pass a graphic." - ) - - # use passed graphic if provided, else use parent - if graphic is not None: - source = graphic - else: - source = self.parent - - return source - - def _key_move(self): - if self._key_move_value: - # step * direction - # TODO: step size in world space intead of screen space - direction = key_bind_direction[self._key_move_value] - delta = Vector3(self.step, self.step, 0).multiply_scalar(direction) - # we provide both x and y, depending on the axis this selector is on the other value is ignored anyways - self._move_graphic(delta=delta) - - def _key_move_start(self, ev): - if self.key_bind_modifier is not None and self.key_bind_modifier not in ev.modifiers: - return - - if self.axis == "x" and ev.key in ["ArrowRight", "ArrowLeft"]: - self._key_move_value = ev.key - - elif self.axis == "y" and ev.key in ["ArrowUp", "ArrowDown"]: - self._key_move_value = ev.key - - def _key_move_end(self, ev): - if self.axis == "x" and ev.key in ["ArrowRight", "ArrowLeft"]: - self._key_move_value = False - - elif self.axis == "y" and ev.key in ["ArrowUp", "ArrowDown"]: - self._key_move_value = False - - def _add_plot_area_hook(self, plot_area): - self._plot_area = plot_area - - # move events - self.world_object.add_event_handler(self._move_start, "pointer_down") - self._plot_area.renderer.add_event_handler(self._move, "pointer_move") - self._plot_area.renderer.add_event_handler(self._move_end, "pointer_up") - - # move directly to location of center mouse button click - self._plot_area.renderer.add_event_handler(self._move_to_pointer, "click") - - # mouse hover color events - self.world_object.add_event_handler(self._pointer_enter, "pointer_enter") - self.world_object.add_event_handler(self._pointer_leave, "pointer_leave") - - # arrow key bindings - self._plot_area.renderer.add_event_handler(self._key_move_start, "key_down") - self._plot_area.renderer.add_event_handler(self._key_move_end, "key_up") - - self._plot_area.add_animations(self._key_move) - - def _move_to_pointer(self, ev): - # middle mouse button clicks - if ev.button != 3: - return - - click_pos = (ev.x, ev.y) - world_pos = self._plot_area.map_screen_to_world(click_pos) - - # outside this viewport - if world_pos is None: - return + if "Line" in graphic.__class__.__name__: + # we want to find the index of the geometry position that is closest to the slider's geometry position + find_value = self.selection() - offset - if self.axis == "x": - self.selection = world_pos.x - else: - self.selection = world_pos.y + # get closest data index to the world space position of the slider + idx = np.searchsorted(geo_positions, find_value, side="left") - def _move_start(self, ev): - self._move_info = {"last_pos": (ev.x, ev.y)} - - def _move(self, ev): - if self._move_info is None: - return + if idx > 0 and (idx == len(geo_positions) or math.fabs(find_value - geo_positions[idx - 1]) < math.fabs(find_value - geo_positions[idx])): + return int(idx - 1) + else: + return int(idx) - self._plot_area.controller.enabled = False + if "Heatmap" in graphic.__class__.__name__ or "Image" in graphic.__class__.__name__: + # indices map directly to grid geometry for image data buffer + index = self.selection() - offset + return int(index) - last = self._move_info["last_pos"] - - # new - last - # pointer move events are in viewport or canvas space - delta = Vector3(ev.x - last[0], ev.y - last[1]) - - self._pygfx_event = ev - - self._move_graphic(delta) - - self._move_info = {"last_pos": (ev.x, ev.y)} - self._plot_area.controller.enabled = True - - def _move_graphic(self, delta: Vector3): + def _move_graphic(self, delta: Vector3, ev): """ - Moves the graphic, updates SelectionFeature + Moves the graphic Parameters ---------- - delta_ndc: Vector3 - the delta by which to move this Graphic, in screen coordinates + delta: Vector3 + delta in world space """ - self.delta = delta.clone() - - viewport_size = self._plot_area.viewport.logical_size - - # convert delta to NDC coordinates using viewport size - # also since these are just deltas we don't have to calculate positions relative to the viewport - delta_ndc = delta.clone().multiply( - Vector3( - 2 / viewport_size[0], - -2 / viewport_size[1], - 0 - ) - ) - - camera = self._plot_area.camera - # current world position - vec = self.position.clone() - - # compute and add delta in projected NDC space and then unproject back to world space - vec.project(camera).add(delta_ndc).unproject(camera) - - new_value = getattr(vec, self.axis) - - if new_value < self.limits[0] or new_value > self.limits[1]: - return - - self.selection = new_value - self.delta = None - - def _move_end(self, ev): - self._move_info = None - self._plot_area.controller.enabled = True - - def _pointer_enter(self, ev): - self.line_outer.geometry.colors.data[:] = np.repeat([pygfx.Color("magenta")], 2, axis=0) - self.line_outer.geometry.colors.update_range() - - def _pointer_leave(self, ev): - if self._move_info is not None: - return - - self._reset_color() - - def _reset_color(self): - self.line_outer.geometry.colors.data[:] = self.colors_outer - self.line_outer.geometry.colors.update_range() + if self.axis == "x": + self.selection = self.selection() + delta.x + else: + self.selection = self.selection() + delta.y diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 8f68a754a..8cd0313a1 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -1,39 +1,14 @@ from typing import * import numpy as np -from functools import partial import pygfx from pygfx.linalg import Vector3 -from .._base import Graphic, Interaction, GraphicCollection +from .._base import Graphic, GraphicCollection from ..features._base import GraphicFeature, FeatureEvent +from ._base_selector import BaseSelector - -# positions for indexing the BoxGeometry to set the "width" and "size" of the box -# hacky, but I don't think we can morph meshes in pygfx yet: https://github.com/pygfx/pygfx/issues/346 -x_right = np.array([ - True, True, True, True, False, False, False, False, False, - True, False, True, True, False, True, False, False, True, - False, True, True, False, True, False -]) - -x_left = np.array([ - False, False, False, False, True, True, True, True, True, - False, True, False, False, True, False, True, True, False, - True, False, False, True, False, True -]) - -y_top = np.array([ - False, True, False, True, False, True, False, True, True, - True, True, True, False, False, False, False, False, False, - True, True, False, False, True, True -]) - -y_bottom = np.array([ - True, False, True, False, True, False, True, False, False, - False, False, False, True, True, True, True, True, True, - False, False, True, True, False, False -]) +from ._mesh_positions import x_right, x_left, y_top, y_bottom class LinearBoundsFeature(GraphicFeature): @@ -52,17 +27,20 @@ class LinearBoundsFeature(GraphicFeature): +--------------------+-------------------------------+--------------------------------------------------------------------------------------+ """ - def __init__(self, parent, bounds: Tuple[int, int], axis: str): + def __init__(self, parent, bounds: Tuple[int, int], axis: str, limits: Tuple[int, int]): super(LinearBoundsFeature, self).__init__(parent, data=bounds) self._axis = axis + self.limits = limits + + self._set(bounds) @property def axis(self) -> str: """one of "x" | "y" """ return self._axis - def _set(self, value): + def _set(self, value: Tuple[float, float]): # sets new bounds if not isinstance(value, tuple): raise TypeError( @@ -70,6 +48,17 @@ def _set(self, value): "where `min_bound` and `max_bound` are numeric values." ) + # make sure bounds not exceeded + for v in value: + if not (self.limits[0] <= v <= self.limits[1]): + return + + # make sure `selector width >= 2`, left edge must not move past right edge! + # or bottom edge must not move past top edge! + # has to be at least 2 otherwise can't join datapoints for lines + if not (value[1] - value[0]) >= 2: + return + if self.axis == "x": # change left x position of the fill mesh self._parent.fill.geometry.positions.data[x_left, 0] = value[0] @@ -132,7 +121,7 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): self._call_event_handlers(event_data) -class LinearRegionSelector(Graphic, Interaction): +class LinearRegionSelector(Graphic, BaseSelector): feature_events = ( "bounds" ) @@ -148,13 +137,17 @@ def __init__( resizable: bool = True, fill_color=(0, 0, 0.35), edge_color=(0.8, 0.8, 0), + arrow_keys_modifier: str = "Shift", name: str = None ): """ Create a LinearRegionSelector graphic which can be moved only along either the x-axis or y-axis. Allows sub-selecting data from a ``Graphic`` or from multiple Graphics. - bounds[0], limits[0], and position[0] must be identical + bounds[0], limits[0], and position[0] must be identical. + + Holding the right mouse button while dragging an edge will force the entire region selector to move. This is + a when using transparent fill areas due to ``pygfx`` picking limitations. Parameters ---------- @@ -174,7 +167,7 @@ def __init__( "x" | "y", axis for the selector parent: Graphic, default ``None`` - associated this selector with a parent Graphic + associate this selector with a parent Graphic resizable: bool if ``True``, the edges can be dragged to resize the width of the linear selection @@ -211,7 +204,7 @@ def __init__( # f"{limits[0]} != {origin[1]} != {bounds[0]}" # ) - super(LinearRegionSelector, self).__init__(name=name) + Graphic.__init__(self, name=name) self.parent = parent @@ -235,23 +228,12 @@ def __init__( # the fill of the selection self.fill = mesh - self.fill.position.set(*origin, -2) self.world_object.add(self.fill) - # will be used to store the mouse pointer x y movements - # so deltas can be calculated for interacting with the selection - self._move_info = None - - # mouse events can come from either the fill mesh world object, or one of the lines on the edge of the selector - self._event_source: str = None - - self.limits = limits self._resizable = resizable - self._edge_color = np.repeat([pygfx.Color(edge_color)], 2, axis=0) - if axis == "x": # position data for the left edge line left_line_data = np.array( @@ -260,8 +242,8 @@ def __init__( ).astype(np.float32) left_line = pygfx.Line( - pygfx.Geometry(positions=left_line_data, colors=self._edge_color.copy()), - pygfx.LineMaterial(thickness=3, vertex_colors=True) + pygfx.Geometry(positions=left_line_data), + pygfx.LineMaterial(thickness=3, color=edge_color) ) # position data for the right edge line @@ -271,8 +253,8 @@ def __init__( ).astype(np.float32) right_line = pygfx.Line( - pygfx.Geometry(positions=right_line_data, colors=self._edge_color.copy()), - pygfx.LineMaterial(thickness=3, vertex_colors=True) + pygfx.Geometry(positions=right_line_data), + pygfx.LineMaterial(thickness=3, color=edge_color) ) self.edges: Tuple[pygfx.Line, pygfx.Line] = (left_line, right_line) @@ -286,8 +268,8 @@ def __init__( ).astype(np.float32) bottom_line = pygfx.Line( - pygfx.Geometry(positions=bottom_line_data, colors=self._edge_color.copy()), - pygfx.LineMaterial(thickness=3, vertex_colors=True) + pygfx.Geometry(positions=bottom_line_data), + pygfx.LineMaterial(thickness=3, color=edge_color) ) # position data for the right edge line @@ -297,28 +279,32 @@ def __init__( ).astype(np.float32) top_line = pygfx.Line( - pygfx.Geometry(positions=top_line_data, colors=self._edge_color.copy()), - pygfx.LineMaterial(thickness=3, vertex_colors=True) + pygfx.Geometry(positions=top_line_data), + pygfx.LineMaterial(thickness=3, color=edge_color) ) self.edges: Tuple[pygfx.Line, pygfx.Line] = (bottom_line, top_line) + else: + raise ValueError("axis argument must be one of 'x' or 'y'") + # add the edge lines for edge in self.edges: edge.position.set_z(-1) self.world_object.add(edge) - # highlight the edges when mouse is hovered - for edge_line in self.edges: - edge_line.add_event_handler( - partial(self._pointer_enter_edge, edge_line), - "pointer_enter" - ) - edge_line.add_event_handler(self._pointer_leave_edge, "pointer_leave") - # set the initial bounds of the selector - self._bounds = LinearBoundsFeature(self, bounds, axis=axis) - self._bounds: LinearBoundsFeature = bounds + self._bounds = LinearBoundsFeature(self, bounds, axis=axis, limits=limits) + # self._bounds: LinearBoundsFeature = bounds + + BaseSelector.__init__( + self, + edges=self.edges, + fill=(self.fill,), + hover_responsive=self.edges, + arrow_keys_modifier=arrow_keys_modifier, + axis=axis, + ) @property def bounds(self) -> LinearBoundsFeature: @@ -355,27 +341,35 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n source = self._get_source(graphic) ixs = self.get_selected_indices(source) - if isinstance(source, GraphicCollection): - # 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() - - for i, g in enumerate(source.graphics): - if ixs[i].size == 0: - data_selections.append(None) - else: - s = slice(ixs[i][0], ixs[i][-1]) - data_selections.append(g.data.buffer.data[s]) - - return source[:].data[s] - # just for one graphic - else: - if ixs.size == 0: - return None - + if "Line" in source.__class__.__name__: + if isinstance(source, GraphicCollection): + # 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() + + for i, g in enumerate(source.graphics): + if ixs[i].size == 0: + data_selections.append(None) + else: + s = slice(ixs[i][0], ixs[i][-1]) + data_selections.append(g.data.buffer.data[s]) + + return source[:].data[s] + # just for one Line graphic + else: + if ixs.size == 0: + return None + + s = slice(ixs[0], ixs[-1]) + return source.data.buffer.data[s] + + if "Heatmap" in source.__class__.__name__ or "Image" in source.__class__.__name__: s = slice(ixs[0], ixs[-1]) - return source.data.buffer.data[s] + if self.axis == "x": + return source.data()[:, s] + elif self.axis == "y": + return source.data()[s] def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, List[np.ndarray]]: """ @@ -384,7 +378,7 @@ def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, Lis as the Line Geometry positions x-vals or y-vals. For example, if if you used a np.linspace(0, 100, 1000) for xvals in your line, then you will have 1,000 x-positions. If the selection ``bounds`` are set to ``(0, 10)``, the returned - indices would be ``(0, 100``. + indices would be ``(0, 100)``. Parameters ---------- @@ -394,7 +388,7 @@ def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, Lis Returns ------- Union[np.ndarray, List[np.ndarray]] - data indices of the selection + data indices of the selection, list of np.ndarray if graphic is LineCollection """ source = self._get_source(graphic) @@ -402,6 +396,7 @@ def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, Lis # if the graphic position is not at (0, 0) then the bounds must be offset offset = getattr(source.position, self.bounds.axis) offset_bounds = tuple(v - offset for v in self.bounds()) + # need them to be int to use as indices offset_bounds = tuple(map(int, offset_bounds)) @@ -409,185 +404,79 @@ def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, Lis dim = 0 else: dim = 1 - # now we need to map from graphic space to data space - # we can have more than 1 datapoint between two integer locations in the world space - if isinstance(source, GraphicCollection): - ixs = list() - for g in source.graphics: - # map for each graphic in the collection - g_ixs = np.where( - (g.data()[:, dim] >= offset_bounds[0]) & (g.data()[:, dim] <= offset_bounds[1]) - )[0] - ixs.append(g_ixs) - else: - # map this only this graphic - ixs = np.where( - (source.data()[:, dim] >= offset_bounds[0]) & (source.data()[:, dim] <= offset_bounds[1]) - )[0] - - return ixs - - def _get_source(self, graphic): - if self.parent is None and graphic is None: - raise AttributeError( - "No Graphic to apply selector. " - "You must either set a ``parent`` Graphic on the selector, or pass a graphic." - ) - - # use passed graphic if provided, else use parent - if graphic is not None: - source = graphic - else: - source = self.parent - - return source - - def _add_plot_area_hook(self, plot_area): - # called when this selector is added to a plot area - self._plot_area = plot_area - - # need partials so that the source of the event is passed to the `_move_start` handler - self._move_start_fill = partial(self._move_start, "fill") - self._move_start_edge_0 = partial(self._move_start, "edge-0") - self._move_start_edge_1 = partial(self._move_start, "edge-1") - - self.fill.add_event_handler(self._move_start_fill, "pointer_down") - - if self._resizable: - self.edges[0].add_event_handler(self._move_start_edge_0, "pointer_down") - self.edges[1].add_event_handler(self._move_start_edge_1, "pointer_down") - - self._plot_area.renderer.add_event_handler(self._move, "pointer_move") - self._plot_area.renderer.add_event_handler(self._move_end, "pointer_up") - - def _move_start(self, event_source: str, ev): - """ - Parameters - ---------- - event_source: str - "fill" | "edge-left" | "edge-right" - - """ - # self._plot_area.controller.enabled = False - # last pointer position - self._move_info = {"last_pos": (ev.x, ev.y)} - self._event_source = event_source - - def _move(self, ev): - if self._move_info is None: - return - # disable the controller, otherwise the panzoom or other controllers will move the camera and will not - # allow the selector to process the mouse events - self._plot_area.controller.enabled = False - - last = self._move_info["last_pos"] - - # new - last - # pointer move events are in viewport or canvas space - delta = Vector3(ev.x - last[0], ev.y - last[1]) + if "Line" in source.__class__.__name__: + # now we need to map from graphic space to data space + # we can have more than 1 datapoint between two integer locations in the world space + if isinstance(source, GraphicCollection): + ixs = list() + for g in source.graphics: + # map for each graphic in the collection + g_ixs = np.where( + (g.data()[:, dim] >= offset_bounds[0]) & (g.data()[:, dim] <= offset_bounds[1]) + )[0] + ixs.append(g_ixs) + else: + # map this only this graphic + ixs = np.where( + (source.data()[:, dim] >= offset_bounds[0]) & (source.data()[:, dim] <= offset_bounds[1]) + )[0] - self._move_info = {"last_pos": (ev.x, ev.y)} + return ixs - viewport_size = self._plot_area.viewport.logical_size - - # convert delta to NDC coordinates using viewport size - # also since these are just deltas we don't have to calculate positions relative to the viewport - delta_ndc = delta.multiply( - Vector3( - 2 / viewport_size[0], - -2 / viewport_size[1], - 0 - ) - ) - - camera = self._plot_area.camera + if "Heatmap" in source.__class__.__name__ or "Image" in source.__class__.__name__: + # indices map directly to grid geometry for image data buffer + ixs = np.arange(*self.bounds(), dtype=int) + return ixs + def _move_graphic(self, delta, ev): # edge-0 bound current world position if self.bounds.axis == "x": - # left bound position - vec0 = Vector3(self.bounds()[0]) - else: - # bottom bound position - vec0 = Vector3(0, self.bounds()[0]) - # compute and add delta in projected NDC space and then unproject back to world space - vec0.project(camera).add(delta_ndc).unproject(camera) + # new left bound position + bound_pos_0 = Vector3(self.bounds()[0]).add(delta) - # edge-1 bound current world position - if self.bounds.axis == "x": - vec1 = Vector3(self.bounds()[1]) + # new right bound position + bound_pos_1 = Vector3(self.bounds()[1]).add(delta) + else: + # new bottom bound position + bound_pos_0 = Vector3(0, self.bounds()[0]).add(delta) + + # new top bound position + bound_pos_1 = Vector3(0, self.bounds()[1]).add(delta) + + # workaround because transparent objects are not pickable in pygfx + if ev is not None: + if 2 in ev.buttons: + force_fill_move = True + else: + force_fill_move = False else: - vec1 = Vector3(0, self.bounds()[1]) - # compute and add delta in projected NDC space and then unproject back to world space - vec1.project(camera).add(delta_ndc).unproject(camera) + force_fill_move = False + + # move entire selector if source was fill + if self._move_info.source == self.fill or force_fill_move: + bound0 = getattr(bound_pos_0, self.bounds.axis) + bound1 = getattr(bound_pos_1, self.bounds.axis) + # set the new bounds + self.bounds = (bound0, bound1) + return + + # if selector is not resizable do nothing + if not self._resizable: + return - if self._event_source == "edge-0": - # change only the left bound or bottom bound - bound0 = getattr(vec0, self.bounds.axis) # gets either vec.x or vec.y + # if resizable, move edges + if self._move_info.source == self.edges[0]: + # change only left or bottom bound + bound0 = getattr(bound_pos_0, self.bounds.axis) bound1 = self.bounds()[1] - elif self._event_source == "edge-1": - # change only the right bound or top bound + elif self._move_info.source == self.edges[1]: + # change only right or top bound bound0 = self.bounds()[0] - bound1 = getattr(vec1, self.bounds.axis) # gets either vec.x or vec.y - - elif self._event_source == "fill": - # move the entire selector - bound0 = getattr(vec0, self.bounds.axis) - bound1 = getattr(vec1, self.bounds.axis) - - # if the limits are met do nothing - if bound0 < self.limits[0] or bound1 > self.limits[1]: - return - - # make sure `selector width >= 2`, left edge must not move past right edge! - # or bottom edge must not move past top edge! - # has to be at least 2 otherwise can't join datapoints for lines - if not (bound1 - bound0) >= 2: + bound1 = getattr(bound_pos_1, self.bounds.axis) + else: return # set the new bounds self.bounds = (bound0, bound1) - - # re-enable the controller - self._plot_area.controller.enabled = True - - def _move_end(self, ev): - self._move_info = None - # sometimes weird stuff happens so we want to make sure the controller is reset - self._plot_area.controller.enabled = True - - self._reset_edge_color() - - def _pointer_enter_edge(self, edge: pygfx.Line, ev): - edge.material.thickness = 6 - edge.geometry.colors.data[:] = np.repeat([pygfx.Color("magenta")], 2, axis=0) - edge.geometry.colors.update_range() - - def _pointer_leave_edge(self, ev): - if self._move_info is not None and self._event_source.startswith("edge"): - return - - self._reset_edge_color() - - def _reset_edge_color(self): - for edge in self.edges: - edge.material.thickness = 3 - edge.geometry.colors.data[:] = self._edge_color - edge.geometry.colors.update_range() - - def _set_feature(self, feature: str, new_data: Any, indices: Any): - pass - - def _reset_feature(self, feature: str): - pass - - def __del__(self): - self.fill.remove_event_handler(self._move_start_fill, "pointer_down") - - if self._resizable: - self.edges[0].remove_event_handler(self._move_start_edge_0, "pointer_down") - self.edges[1].remove_event_handler(self._move_start_edge_1, "pointer_down") - - self._plot_area.renderer.remove_event_handler(self._move, "pointer_move") - self._plot_area.renderer.remove_event_handler(self._move_end, "pointer_up") diff --git a/fastplotlib/graphics/selectors/_mesh_positions.py b/fastplotlib/graphics/selectors/_mesh_positions.py new file mode 100644 index 000000000..9542aee58 --- /dev/null +++ b/fastplotlib/graphics/selectors/_mesh_positions.py @@ -0,0 +1,31 @@ +import numpy as np + + +""" +positions for indexing the BoxGeometry to set the "width" and "size" of the box +hacky, but I don't think we can morph meshes in pygfx yet: https://github.com/pygfx/pygfx/issues/346 +""" + +x_right = np.array([ + True, True, True, True, False, False, False, False, False, + True, False, True, True, False, True, False, False, True, + False, True, True, False, True, False +]) + +x_left = np.array([ + False, False, False, False, True, True, True, True, True, + False, True, False, False, True, False, True, True, False, + True, False, False, True, False, True +]) + +y_top = np.array([ + False, True, False, True, False, True, False, True, True, + True, True, True, False, False, False, False, False, False, + True, True, False, False, True, True +]) + +y_bottom = np.array([ + True, False, True, False, True, False, True, False, False, + False, False, False, True, True, True, True, True, True, + False, False, True, True, False, False +]) \ No newline at end of file diff --git a/fastplotlib/graphics/selectors/_rectangle_region.py b/fastplotlib/graphics/selectors/_rectangle_region.py new file mode 100644 index 000000000..5188161b9 --- /dev/null +++ b/fastplotlib/graphics/selectors/_rectangle_region.py @@ -0,0 +1,304 @@ +from typing import * +import numpy as np + +import pygfx +from pygfx.linalg import Vector3 + +from .._base import Graphic, GraphicCollection +from ..features._base import GraphicFeature, FeatureEvent +from ._base_selector import BaseSelector + +from ._mesh_positions import x_right, x_left, y_top, y_bottom + + +class RectangleBoundsFeature(GraphicFeature): + """ + Feature for a linearly bounding region + + Pick Info + --------- + + +--------------------+-------------------------------+--------------------------------------------------------------------------------------+ + | key | type | description | + +====================+===============================+======================================================================================+ + | "selected_indices" | ``numpy.ndarray`` or ``None`` | selected graphic data indices | + | "selected_data" | ``numpy.ndarray`` or ``None`` | selected graphic data | + | "new_data" | ``(float, float)`` | current bounds in world coordinates, NOT necessarily the same as "selected_indices". | + +--------------------+-------------------------------+--------------------------------------------------------------------------------------+ + + """ + def __init__(self, parent, bounds: Tuple[int, int], axis: str, limits: Tuple[int, int]): + super(RectangleBoundsFeature, self).__init__(parent, data=bounds) + + self._axis = axis + self.limits = limits + + self._set(bounds) + + @property + def axis(self) -> str: + """one of "x" | "y" """ + return self._axis + + def _set(self, value: Tuple[float, float, float, float]): + """ + + Parameters + ---------- + value: Tuple[float] + new values: (xmin, xmax, ymin, ymax) + + Returns + ------- + + """ + xmin, xmax, ymin, ymax = value + + # TODO: make sure new values do not exceed limits + + # change fill mesh + # change left x position of the fill mesh + self._parent.fill.geometry.positions.data[x_left, 0] = xmin + + # change right x position of the fill mesh + self._parent.fill.geometry.positions.data[x_right, 0] = xmax + + # change bottom y position of the fill mesh + self._parent.fill.geometry.positions.data[y_bottom, 1] = ymin + + # change top position of the fill mesh + self._parent.fill.geometry.positions.data[y_top, 1] = ymax + + # change the edge lines + + # [x0, y0, z0] + # [x1, y1, z0] + + # left line + z = self._parent.edges[0].geometry.positions.data[:, -1][0] + self._parent.edges[0].geometry.position.data[:] = np.array( + [ + [xmin, ymin, z], + [xmin, ymax, z] + ] + ) + + # right line + self._parent.edges[1].geometry.position.data[:] = np.array( + [ + [xmax, ymin, z], + [xmax, ymax, z] + ] + ) + + # bottom line + self._parent.edges[2].geometry.position.data[:] = np.array( + [ + [xmin, ymin, z], + [xmax, ymin, z] + ] + ) + + # top line + self._parent.edges[3].geometry.position.data[:] = np.array( + [ + [xmin, ymax, z], + [xmax, ymax, z] + ] + ) + + self._data = value#(value[0], value[1]) + + # send changes to GPU + self._parent.fill.geometry.positions.update_range() + + for edge in self._parent.edges: + edge.geometry.positions.update_range() + + # calls any events + self._feature_changed(key=None, new_data=value) + + def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): + return + + if len(self._event_handlers) < 1: + return + + if self._parent.parent is not None: + selected_ixs = self._parent.get_selected_indices() + selected_data = self._parent.get_selected_data() + else: + selected_ixs = None + selected_data = None + + pick_info = { + "index": None, + "collection-index": self._collection_index, + "world_object": self._parent.world_object, + "new_data": new_data, + "selected_indices": selected_ixs, + "selected_data": selected_data + } + + event_data = FeatureEvent(type="bounds", pick_info=pick_info) + + self._call_event_handlers(event_data) + + +class RectangleRegionSelector(Graphic, BaseSelector): + feature_events = ( + "bounds" + ) + + def __init__( + self, + bounds: Tuple[int, int, int, int], + limits: Tuple[int, int], + origin: Tuple[int, int], + axis: str = "x", + parent: Graphic = None, + resizable: bool = True, + fill_color=(0, 0, 0.35), + edge_color=(0.8, 0.8, 0), + arrow_keys_modifier: str = "Shift", + name: str = None + ): + """ + Create a LinearRegionSelector graphic which can be moved only along either the x-axis or y-axis. + Allows sub-selecting data from a ``Graphic`` or from multiple Graphics. + + bounds[0], limits[0], and position[0] must be identical + + Parameters + ---------- + bounds: (int, int, int, int) + the initial bounds of the rectangle, ``(x_min, x_max, y_min, y_max)`` + + limits: (int, int, int, int) + limits of the selector, ``(x_min, x_max, y_min, y_max)`` + + origin: (int, int) + initial position of the selector + + axis: str, default ``None`` + Restrict the selector to the "x" or "y" axis. + If the selector is restricted to an axis you cannot change the bounds along the other axis. For example, + if you set ``axis="x"``, then the ``y_min``, ``y_max`` values of the bounds will stay constant. + + parent: Graphic, default ``None`` + associate this selector with a parent Graphic + + resizable: bool + if ``True``, the edges can be dragged to resize the selection + + fill_color: str, array, or tuple + fill color for the selector, passed to pygfx.Color + + edge_color: str, array, or tuple + edge color for the selector, passed to pygfx.Color + + name: str + name for this selector graphic + """ + + # lots of very close to zero values etc. so round them + bounds = tuple(map(round, bounds)) + limits = tuple(map(round, limits)) + origin = tuple(map(round, origin)) + + Graphic.__init__(self, name=name) + + self.parent = parent + + # world object for this will be a group + # basic mesh for the fill area of the selector + # line for each edge of the selector + group = pygfx.Group() + self._set_world_object(group) + + xmin, xmax, ymin, ymax = bounds + + width = xmax - xmin + height = ymax - ymin + + self.fill = pygfx.Mesh( + pygfx.box_geometry(width, height, 1), + pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)) + ) + + self.fill.position.set(*origin, -2) + self.world_object.add(self.fill) + + # position data for the left edge line + left_line_data = np.array( + [[origin[0], (-height / 2) + origin[1], 0.5], + [origin[0], (height / 2) + origin[1], 0.5]] + ).astype(np.float32) + + left_line = pygfx.Line( + pygfx.Geometry(positions=left_line_data), + pygfx.LineMaterial(thickness=3, color=edge_color) + ) + + # position data for the right edge line + right_line_data = np.array( + [[bounds[1], (-height / 2) + origin[1], 0.5], + [bounds[1], (height / 2) + origin[1], 0.5]] + ).astype(np.float32) + + right_line = pygfx.Line( + pygfx.Geometry(positions=right_line_data), + pygfx.LineMaterial(thickness=3, color=edge_color) + ) + + # position data for the left edge line + bottom_line_data = \ + np.array( + [[(-width / 2) + origin[0], origin[1], 0.5], + [(width / 2) + origin[0], origin[1], 0.5]] + ).astype(np.float32) + + bottom_line = pygfx.Line( + pygfx.Geometry(positions=bottom_line_data), + pygfx.LineMaterial(thickness=3, color=edge_color) + ) + + # position data for the right edge line + top_line_data = np.array( + [[(-width / 2) + origin[0], bounds[1], 0.5], + [(width / 2) + origin[0], bounds[1], 0.5]] + ).astype(np.float32) + + top_line = pygfx.Line( + pygfx.Geometry(positions=top_line_data), + pygfx.LineMaterial(thickness=3, color=edge_color) + ) + + self.edges: Tuple[pygfx.Line, ...] = ( + left_line, right_line, bottom_line, top_line + ) # left line, right line, bottom line, top line + + # add the edge lines + for edge in self.edges: + edge.position.set_z(-1) + self.world_object.add(edge) + + self._bounds = RectangleBoundsFeature(self, bounds, axis=axis, limits=limits) + + BaseSelector.__init__( + self, + edges=self.edges, + fill=(self.fill,), + hover_responsive=self.edges, + arrow_keys_modifier=arrow_keys_modifier, + axis=axis, + ) + + @property + def bounds(self) -> RectangleBoundsFeature: + """ + The current bounds of the selection in world space. These bounds will NOT necessarily correspond to the + indices of the data that are under the selection. Use ``get_selected_indices()` which maps from + world space to data indices. + """ + return self._bounds \ No newline at end of file diff --git a/fastplotlib/graphics/selectors/_sync.py b/fastplotlib/graphics/selectors/_sync.py index 15cb01726..72d25b542 100644 --- a/fastplotlib/graphics/selectors/_sync.py +++ b/fastplotlib/graphics/selectors/_sync.py @@ -16,7 +16,7 @@ def __init__(self, *selectors: LinearSelector, key_bind: str = "Shift"): selectors to synchronize key_bind: str, default ``"Shift"`` - one of ``"Control"``, ``"Shift"`` and ``"Alt"`` + one of ``"Control"``, ``"Shift"`` and ``"Alt"`` or ``None`` """ self._selectors = list() self.key_bind = key_bind