From f045c7ea7624453e1e09dc17d8f6efe57a6a2028 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 14 Apr 2023 02:41:44 -0400 Subject: [PATCH 01/25] linear selector, basic functionality --- fastplotlib/graphics/features/_base.py | 2 +- fastplotlib/graphics/selectors/__init__.py | 0 fastplotlib/graphics/selectors/linear.py | 157 +++++++++++++++++++++ fastplotlib/layouts/_base.py | 4 +- fastplotlib/plot.py | 5 +- 5 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 fastplotlib/graphics/selectors/__init__.py create mode 100644 fastplotlib/graphics/selectors/linear.py diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index da6a177a0..94990fd0a 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -145,7 +145,7 @@ def _call_event_handlers(self, event_data: FeatureEvent): func(event_data) else: func() - except: + except TypeError: warn(f"Event handler {func} has an unresolvable argspec, calling it without arguments") func() diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/graphics/selectors/linear.py b/fastplotlib/graphics/selectors/linear.py new file mode 100644 index 000000000..52c6d37ef --- /dev/null +++ b/fastplotlib/graphics/selectors/linear.py @@ -0,0 +1,157 @@ +from typing import * +import numpy as np + +import pygfx +from pygfx.linalg import Vector3 + +from .._base import Graphic, Interaction +from ..features._base import GraphicFeature, FeatureEvent + + +# positions for indexing the BoxGeometry to set the "width" and "height" 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 +]) + + +class LinearBoundsFeature(GraphicFeature): + def __init__(self, parent, bounds: Tuple[int, int]): + super(LinearBoundsFeature, self).__init__(parent, data=bounds) + + def _set(self, value): + # sets new bounds + if not isinstance(value, tuple): + raise TypeError( + "Bounds must be a tuple in the form of `(min_bound, max_bound)`, " + "where `min_bound` and `max_bound` are numeric values." + ) + + self._parent.fill.geometry.positions.data[x_left, 0] = value[0] + self._parent.fill.geometry.positions.data[x_right, 0] = value[1] + self._data = (value[0], value[1]) + + self._parent.fill.geometry.positions.update_range() + + self._feature_changed(key=None, new_data=value) + + def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): + pick_info = { + "index": None, + "collection-index": self._collection_index, + "world_object": self._parent.world_object, + "new_data": new_data + } + + event_data = FeatureEvent(type="bounds", pick_info=pick_info) + + self._call_event_handlers(event_data) + + +class LinearSelector(Graphic, Interaction): + """Linear region selector, for lines or line collections.""" + feature_events = ( + "bounds" + ) + + def __init__( + self, + bounds: Tuple[int, int], + limits: Tuple[int, int], + height: int, + position: Tuple[int, int], + fill_color=(0.1, 0.1, 0.1), + edge_color="w", + name: str = None + ): + super(LinearSelector, self).__init__(name=name) + + group = pygfx.Group() + self._set_world_object(group) + + self.fill = pygfx.Mesh( + pygfx.box_geometry(1, height, 1), + pygfx.MeshBasicMaterial(color=fill_color) + ) + + self.fill.position.set(*position, -2) + + self.world_object.add(self.fill) + + self._move_info = None + + self.edges = None + + self.bounds = LinearBoundsFeature(self, bounds) + self.bounds = bounds + self.timer = 0 + + self.limits = limits + + def _add_plot_area_hook(self, plot_area): + # called when this selector is added to a plot area + self._plot_area = plot_area + + self.fill.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") + + def _move_start(self, ev): + self._plot_area.controller.enabled = False + self._move_info = {"last_pos": (ev.x, ev.y)} + + def _move(self, ev): + if self._move_info is None: + return + + self._plot_area.controller.enabled = False + + last = self._move_info["last_pos"] + + delta = (last[0] - ev.x, last[1] - ev.y) + + self._move_info = {"last_pos": (ev.x, ev.y)} + + # clip based on the limits + left_bound = self.bounds()[0] - delta[0] + right_bound = self.bounds()[1] - delta[0] + + print(left_bound, right_bound) + + if left_bound <= self.limits[0] or right_bound >= self.limits[1]: + self._move_end(None) + return + + # set the new bounds + self.bounds = (left_bound, right_bound) + + self._plot_area.controller.enabled = True + + def _move_end(self, ev): + self._move_info = None + + def _set_feature(self, feature: str, new_data: Any, indices: Any): + pass + + def _reset_feature(self, feature: str): + pass \ No newline at end of file diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index 70bd6dbaa..7e47b185f 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -193,8 +193,8 @@ def add_graphic(self, graphic: Graphic, center: bool = True): if center: self.center_graphic(graphic) - # if hasattr(graphic, "_add_plot_area_hook"): - # graphic._add_plot_area_hook(self.viewport, self.camera) + if hasattr(graphic, "_add_plot_area_hook"): + graphic._add_plot_area_hook(self) def _check_graphic_name_exists(self, name): graphic_names = list() diff --git a/fastplotlib/plot.py b/fastplotlib/plot.py index 89c73a5f2..97e19effd 100644 --- a/fastplotlib/plot.py +++ b/fastplotlib/plot.py @@ -89,7 +89,7 @@ def render(self): self.renderer.flush() self.canvas.request_draw() - def show(self): + def show(self, autoscale: bool = True): """ begins the rendering event loop and returns the canvas @@ -100,6 +100,7 @@ def show(self): """ self.canvas.request_draw(self.render) - self.auto_scale(maintain_aspect=True, zoom=0.95) + if autoscale: + self.auto_scale(maintain_aspect=True, zoom=0.95) return self.canvas From 6c5bc77ab6ac433ac71d659b3b8a045c9900a819 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 14 Apr 2023 03:26:52 -0400 Subject: [PATCH 02/25] edges move with fill --- fastplotlib/graphics/selectors/linear.py | 62 +++++++++++++++++++++--- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/fastplotlib/graphics/selectors/linear.py b/fastplotlib/graphics/selectors/linear.py index 52c6d37ef..7457b7998 100644 --- a/fastplotlib/graphics/selectors/linear.py +++ b/fastplotlib/graphics/selectors/linear.py @@ -1,5 +1,6 @@ from typing import * import numpy as np +from functools import partial import pygfx from pygfx.linalg import Vector3 @@ -49,10 +50,17 @@ def _set(self, value): self._parent.fill.geometry.positions.data[x_left, 0] = value[0] self._parent.fill.geometry.positions.data[x_right, 0] = value[1] + + self._parent.edges[0].geometry.positions.data[:, 0] = value[0] + self._parent.edges[1].geometry.positions.data[:, 0] = value[1] + self._data = (value[0], value[1]) self._parent.fill.geometry.positions.update_range() + self._parent.edges[0].geometry.positions.update_range() + self._parent.edges[1].geometry.positions.update_range() + self._feature_changed(key=None, new_data=value) def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): @@ -80,10 +88,26 @@ def __init__( limits: Tuple[int, int], height: int, position: Tuple[int, int], - fill_color=(0.1, 0.1, 0.1), - edge_color="w", + resizable: bool = False, + fill_color=(0, 0, 0.5), + edge_color=(0.8, 0.8, 0), name: str = None ): + """ + + Parameters + ---------- + bounds + limits + height + position + fill_color + edge_color + name + """ + if limits[0] != position[0] != bounds[0]: + raise ValueError("limits[0] != position[0] != bounds[0]") + super(LinearSelector, self).__init__(name=name) group = pygfx.Group() @@ -100,13 +124,35 @@ def __init__( self._move_info = None - self.edges = None + self.limits = limits + + left_line_data = np.array( + [[position[0], (-height / 2) + position[1], 0.5], + [position[0], (height / 2) + position[1], 0.5]] + ).astype(np.float32) + + left_line = pygfx.Line( + pygfx.Geometry(positions=left_line_data, colors=np.repeat([pygfx.Color(edge_color)], 2, axis=0)), + pygfx.LineMaterial(thickness=2, vertex_colors=True) + ) + + right_line_data = np.array( + [[bounds[1], (-height / 2) + position[1], 0.5], + [bounds[1], (height / 2) + position[1], 0.5]] + ).astype(np.float32) + + right_line = pygfx.Line( + pygfx.Geometry(positions=right_line_data, colors=np.repeat([pygfx.Color(edge_color)], 2, axis=0)), + pygfx.LineMaterial(thickness=2, vertex_colors=True) + ) + + self.world_object.add(left_line) + self.world_object.add(right_line) + + self.edges: Tuple[pygfx.Line, pygfx.Line] = (left_line, right_line) self.bounds = LinearBoundsFeature(self, bounds) self.bounds = bounds - self.timer = 0 - - self.limits = limits def _add_plot_area_hook(self, plot_area): # called when this selector is added to a plot area @@ -136,8 +182,6 @@ def _move(self, ev): left_bound = self.bounds()[0] - delta[0] right_bound = self.bounds()[1] - delta[0] - print(left_bound, right_bound) - if left_bound <= self.limits[0] or right_bound >= self.limits[1]: self._move_end(None) return @@ -149,6 +193,8 @@ def _move(self, ev): 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 def _set_feature(self, feature: str, new_data: Any, indices: Any): pass From 39c61507a93ed8c487f2e69d6d53e0396ff2a6ec Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 14 Apr 2023 04:14:01 -0400 Subject: [PATCH 03/25] linear selector width can change by dragging edge lines --- fastplotlib/graphics/selectors/linear.py | 43 +++++++++++++++++++----- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/fastplotlib/graphics/selectors/linear.py b/fastplotlib/graphics/selectors/linear.py index 7457b7998..cc0c873e3 100644 --- a/fastplotlib/graphics/selectors/linear.py +++ b/fastplotlib/graphics/selectors/linear.py @@ -89,7 +89,7 @@ def __init__( height: int, position: Tuple[int, int], resizable: bool = False, - fill_color=(0, 0, 0.5), + fill_color=(0, 0, 0.35), edge_color=(0.8, 0.8, 0), name: str = None ): @@ -123,8 +123,10 @@ def __init__( self.world_object.add(self.fill) self._move_info = None + self._event_source: str = None self.limits = limits + self._resizable = resizable left_line_data = np.array( [[position[0], (-height / 2) + position[1], 0.5], @@ -133,7 +135,7 @@ def __init__( left_line = pygfx.Line( pygfx.Geometry(positions=left_line_data, colors=np.repeat([pygfx.Color(edge_color)], 2, axis=0)), - pygfx.LineMaterial(thickness=2, vertex_colors=True) + pygfx.LineMaterial(thickness=5, vertex_colors=True) ) right_line_data = np.array( @@ -143,7 +145,7 @@ def __init__( right_line = pygfx.Line( pygfx.Geometry(positions=right_line_data, colors=np.repeat([pygfx.Color(edge_color)], 2, axis=0)), - pygfx.LineMaterial(thickness=2, vertex_colors=True) + pygfx.LineMaterial(thickness=5, vertex_colors=True) ) self.world_object.add(left_line) @@ -158,13 +160,23 @@ def _add_plot_area_hook(self, plot_area): # called when this selector is added to a plot area self._plot_area = plot_area - self.fill.add_event_handler(self._move_start, "pointer_down") + move_start_fill = partial(self._move_start, "fill") + move_start_edge_left = partial(self._move_start, "edge-left") + move_start_edge_right = partial(self._move_start, "edge-right") + + self.fill.add_event_handler(move_start_fill, "pointer_down") + + if self._resizable: + self.edges[0].add_event_handler(move_start_edge_left, "pointer_down") + self.edges[1].add_event_handler(move_start_edge_right, "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, ev): + def _move_start(self, event_source: str, ev): self._plot_area.controller.enabled = False self._move_info = {"last_pos": (ev.x, ev.y)} + self._event_source = event_source def _move(self, ev): if self._move_info is None: @@ -178,12 +190,25 @@ def _move(self, ev): self._move_info = {"last_pos": (ev.x, ev.y)} + if self._event_source == "edge-left": + left_bound = self.bounds()[0] - delta[0] + right_bound = self.bounds()[1] + + elif self._event_source == "edge-right": + left_bound = self.bounds()[0] + right_bound = self.bounds()[1] - delta[0] + + elif self._event_source == "fill": + left_bound = self.bounds()[0] - delta[0] + right_bound = self.bounds()[1] - delta[0] + # clip based on the limits - left_bound = self.bounds()[0] - delta[0] - right_bound = self.bounds()[1] - delta[0] + if left_bound < self.limits[0] or right_bound > self.limits[1]: + return - if left_bound <= self.limits[0] or right_bound >= self.limits[1]: - self._move_end(None) + # make sure width > 2 + # has to be at least 2 otherwise can't join datapoints for lines + if (right_bound - left_bound) < 2: return # set the new bounds From 81c85639f1712aa745dab2823c5c742d1907275e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 14 Apr 2023 04:59:39 -0400 Subject: [PATCH 04/25] docstring --- fastplotlib/graphics/selectors/linear.py | 34 ++++++++++++++++++------ 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/fastplotlib/graphics/selectors/linear.py b/fastplotlib/graphics/selectors/linear.py index cc0c873e3..a9bf6a341 100644 --- a/fastplotlib/graphics/selectors/linear.py +++ b/fastplotlib/graphics/selectors/linear.py @@ -94,17 +94,35 @@ def __init__( name: str = None ): """ + Create a LinearSelector graphic which can be moved only along the x-axis. Useful for sub-selecting + data Line graphics or Heatmap graphics. + + bounds[0], limits[0], and position[0] must be identical Parameters ---------- - bounds - limits - height - position - fill_color - edge_color - name + bounds: (int, int) + the initial bounds of the linear selector + + limits: (int, int) + (min limit, max limit) for the selector + + height: int + height of the selector + + position: (int, int) + initial position of the selector + + 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 """ + if limits[0] != position[0] != bounds[0]: raise ValueError("limits[0] != position[0] != bounds[0]") @@ -115,7 +133,7 @@ def __init__( self.fill = pygfx.Mesh( pygfx.box_geometry(1, height, 1), - pygfx.MeshBasicMaterial(color=fill_color) + pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)) ) self.fill.position.set(*position, -2) From 885a0825d9402cd561e73e66a589ea91e7a5e49d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 14 Apr 2023 21:23:26 -0400 Subject: [PATCH 05/25] comments --- fastplotlib/graphics/selectors/linear.py | 47 ++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/fastplotlib/graphics/selectors/linear.py b/fastplotlib/graphics/selectors/linear.py index a9bf6a341..bf0b99aa6 100644 --- a/fastplotlib/graphics/selectors/linear.py +++ b/fastplotlib/graphics/selectors/linear.py @@ -37,6 +37,7 @@ class LinearBoundsFeature(GraphicFeature): + """Feature for a linear bounding region""" def __init__(self, parent, bounds: Tuple[int, int]): super(LinearBoundsFeature, self).__init__(parent, data=bounds) @@ -48,19 +49,27 @@ def _set(self, value): "where `min_bound` and `max_bound` are numeric values." ) + # change left x position of the fill mesh self._parent.fill.geometry.positions.data[x_left, 0] = value[0] + + # change right x position of the fill mesh self._parent.fill.geometry.positions.data[x_right, 0] = value[1] + # change position of the left edge line self._parent.edges[0].geometry.positions.data[:, 0] = value[0] + + # change position of the right edge line self._parent.edges[1].geometry.positions.data[:, 0] = value[1] self._data = (value[0], value[1]) + # send changes to GPU self._parent.fill.geometry.positions.update_range() self._parent.edges[0].geometry.positions.update_range() self._parent.edges[1].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): @@ -113,6 +122,9 @@ def __init__( position: (int, int) initial position of the selector + resizable: bool + if ``True``, the edges can be dragged to resize the width of the linear selection + fill_color: str, array, or tuple fill color for the selector, passed to pygfx.Color @@ -128,9 +140,13 @@ def __init__( super(LinearSelector, self).__init__(name=name) + # 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) + # the fill of the selection self.fill = pygfx.Mesh( pygfx.box_geometry(1, height, 1), pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)) @@ -140,12 +156,17 @@ def __init__( 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 + # position data for the left edge line left_line_data = np.array( [[position[0], (-height / 2) + position[1], 0.5], [position[0], (height / 2) + position[1], 0.5]] @@ -156,6 +177,7 @@ def __init__( pygfx.LineMaterial(thickness=5, vertex_colors=True) ) + # position data for the right edge line right_line_data = np.array( [[bounds[1], (-height / 2) + position[1], 0.5], [bounds[1], (height / 2) + position[1], 0.5]] @@ -166,11 +188,13 @@ def __init__( pygfx.LineMaterial(thickness=5, vertex_colors=True) ) + # add the edge lines self.world_object.add(left_line) self.world_object.add(right_line) self.edges: Tuple[pygfx.Line, pygfx.Line] = (left_line, right_line) + # set the initial bounds of the selector self.bounds = LinearBoundsFeature(self, bounds) self.bounds = bounds @@ -178,6 +202,7 @@ 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 move_start_fill = partial(self._move_start, "fill") move_start_edge_left = partial(self._move_start, "edge-left") move_start_edge_right = partial(self._move_start, "edge-right") @@ -192,7 +217,15 @@ def _add_plot_area_hook(self, plot_area): self._plot_area.renderer.add_event_handler(self._move_end, "pointer_up") def _move_start(self, event_source: str, ev): - self._plot_area.controller.enabled = False + """ + 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 @@ -200,6 +233,8 @@ 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"] @@ -209,22 +244,27 @@ def _move(self, ev): self._move_info = {"last_pos": (ev.x, ev.y)} if self._event_source == "edge-left": + # change only the left bound + # move the left edge only, expand the fill in the leftward direction left_bound = self.bounds()[0] - delta[0] right_bound = self.bounds()[1] elif self._event_source == "edge-right": + # change only the right bound + # move the right edge only, expand the fill in the rightward direction left_bound = self.bounds()[0] right_bound = self.bounds()[1] - delta[0] elif self._event_source == "fill": + # move the entire selector left_bound = self.bounds()[0] - delta[0] right_bound = self.bounds()[1] - delta[0] - # clip based on the limits + # if the limits are met do nothing if left_bound < self.limits[0] or right_bound > self.limits[1]: return - # make sure width > 2 + # make sure `selector width > 2`, left edge must not move past right edge! # has to be at least 2 otherwise can't join datapoints for lines if (right_bound - left_bound) < 2: return @@ -232,6 +272,7 @@ def _move(self, ev): # set the new bounds self.bounds = (left_bound, right_bound) + # re-enable the controller self._plot_area.controller.enabled = True def _move_end(self, ev): From 1ab25e665e6c2ff3ef9d16cc3e3f7df6d867bb6c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 15 Apr 2023 02:46:25 -0400 Subject: [PATCH 06/25] highlight linear selector edges on mouse hover --- fastplotlib/graphics/selectors/linear.py | 43 +++++++++++++++++++++--- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/fastplotlib/graphics/selectors/linear.py b/fastplotlib/graphics/selectors/linear.py index bf0b99aa6..c1422505a 100644 --- a/fastplotlib/graphics/selectors/linear.py +++ b/fastplotlib/graphics/selectors/linear.py @@ -97,6 +97,7 @@ def __init__( limits: Tuple[int, int], height: int, position: Tuple[int, int], + parent: Graphic = None, resizable: bool = False, fill_color=(0, 0, 0.35), edge_color=(0.8, 0.8, 0), @@ -140,6 +141,8 @@ def __init__( super(LinearSelector, self).__init__(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 @@ -166,6 +169,8 @@ def __init__( self.limits = limits self._resizable = resizable + self._edge_color = np.repeat([pygfx.Color(edge_color)], 2, axis=0) + # position data for the left edge line left_line_data = np.array( [[position[0], (-height / 2) + position[1], 0.5], @@ -173,8 +178,8 @@ def __init__( ).astype(np.float32) left_line = pygfx.Line( - pygfx.Geometry(positions=left_line_data, colors=np.repeat([pygfx.Color(edge_color)], 2, axis=0)), - pygfx.LineMaterial(thickness=5, vertex_colors=True) + pygfx.Geometry(positions=left_line_data, colors=self._edge_color.copy()), + pygfx.LineMaterial(thickness=3, vertex_colors=True) ) # position data for the right edge line @@ -184,8 +189,8 @@ def __init__( ).astype(np.float32) right_line = pygfx.Line( - pygfx.Geometry(positions=right_line_data, colors=np.repeat([pygfx.Color(edge_color)], 2, axis=0)), - pygfx.LineMaterial(thickness=5, vertex_colors=True) + pygfx.Geometry(positions=right_line_data, colors=self._edge_color.copy()), + pygfx.LineMaterial(thickness=3, vertex_colors=True) ) # add the edge lines @@ -194,6 +199,17 @@ def __init__( self.edges: Tuple[pygfx.Line, pygfx.Line] = (left_line, right_line) + # 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( + partial(self._pointer_leave_edge, edge_line), + "pointer_leave" + ) + # set the initial bounds of the selector self.bounds = LinearBoundsFeature(self, bounds) self.bounds = bounds @@ -280,6 +296,25 @@ def _move_end(self, ev): # 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, edge: pygfx.Line, 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 From ae11b626da989e69f3e4b4650a7347e12d39859a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 15 Apr 2023 03:30:27 -0400 Subject: [PATCH 07/25] add get_selected_data() --- fastplotlib/graphics/selectors/linear.py | 32 +++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/fastplotlib/graphics/selectors/linear.py b/fastplotlib/graphics/selectors/linear.py index c1422505a..d9799c8cf 100644 --- a/fastplotlib/graphics/selectors/linear.py +++ b/fastplotlib/graphics/selectors/linear.py @@ -5,7 +5,7 @@ import pygfx from pygfx.linalg import Vector3 -from .._base import Graphic, Interaction +from .._base import Graphic, Interaction, GraphicCollection from ..features._base import GraphicFeature, FeatureEvent @@ -206,7 +206,7 @@ def __init__( "pointer_enter" ) edge_line.add_event_handler( - partial(self._pointer_leave_edge, edge_line), + partial(self._pointer_leave_edge), "pointer_leave" ) @@ -214,6 +214,32 @@ def __init__( self.bounds = LinearBoundsFeature(self, bounds) self.bounds = bounds + def get_selected_data(self) -> Union[np.ndarray, List[np.ndarray]]: + """ + Get the ``Graphic`` data bounded by the current selection. + Returns a view of the full data array. + If the ``Graphic`` is a collection, such as a ``LineStack``, it returns a list of views of the full array. + + Returns + ------- + Union[np.ndarray, List[np.ndarray]] + view or list of views of the full array + + """ + if self.parent is None: + raise AttributeError("No parent Graphic associated with selector") + + # slice along x-axis + x_slice = slice(*self.bounds()) + + if isinstance(self.parent, 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 + return self.parent[:].data[x_slice] + + return self.parent.data.buffer.data[x_slice] + def _add_plot_area_hook(self, plot_area): # called when this selector is added to a plot area self._plot_area = plot_area @@ -303,7 +329,7 @@ def _pointer_enter_edge(self, edge: pygfx.Line, ev): edge.geometry.colors.data[:] = np.repeat([pygfx.Color("magenta")], 2, axis=0) edge.geometry.colors.update_range() - def _pointer_leave_edge(self, edge: pygfx.Line, ev): + def _pointer_leave_edge(self, ev): if self._move_info is not None and self._event_source.startswith("edge"): return From 9e919665a8d071dabaa85e9e70c7dcebc4fd4b49 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 15 Apr 2023 04:22:15 -0400 Subject: [PATCH 08/25] can add selector from linegraphic, organization --- fastplotlib/graphics/line.py | 59 +++++++++++++++++++ fastplotlib/graphics/selectors/__init__.py | 1 + .../selectors/{linear.py => _linear.py} | 19 +++--- 3 files changed, 70 insertions(+), 9 deletions(-) rename fastplotlib/graphics/selectors/{linear.py => _linear.py} (95%) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 0b1e579bc..6844ff327 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -4,6 +4,7 @@ from ._base import Graphic, Interaction, PreviouslyModifiedData from .features import PointsDataFeature, ColorFeature, CmapFeature, ThicknessFeature +from .selectors import LinearSelector from ..utils import make_colors @@ -96,6 +97,64 @@ def __init__( if z_position is not None: self.world_object.position.z = z_position + self.selectors: List[LinearSelector] = list() + + def add_linear_selector(self, padding: float = 100.0, **kwargs): + """ + Add a ``LinearSelector``. + + Parameters + ---------- + padding: float, default 100.0 + Extends the linear selector along the y-axis to make it easier to interact with. + + kwargs + passed to ``LinearSelector`` + + Returns + ------- + LinearSelector + linear selection graphic + """ + data = self.data() + # x limits + x_limits = (data[0, 0], data[-1, 0]) + + # initial bounds are 20% of the limits range + bounds_init = (x_limits[0], int(np.ptp(x_limits) * 0.2)) + + # width of the y-vals + padding + height = np.ptp(data[:, 1]) + padding + + # initial position of the selector + position_y = (data[:, 1].min() + data[:, 1].max()) / 2 + position = (x_limits[0], position_y) + + # create selector + selector = LinearSelector( + bounds=bounds_init, + limits=x_limits, + height=height, + position=position, + parent=self, + **kwargs + ) + + self._plot_area.add_graphic(selector, center=False) + # so that it is below this graphic + selector.position.set_z(self.position.z - 1) + + self.selectors.append(selector) + + return selector + + def remove_selector(self, selector: LinearSelector): + self.selectors.remove(selector) + self._plot_area.delete_graphic(selector) + + def _add_plot_area_hook(self, plot_area): + self._plot_area = plot_area + def _set_feature(self, feature: str, new_data: Any, indices: Any = None): if not hasattr(self, "_previous_data"): self._previous_data = dict() diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index e69de29bb..552b938ef 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -0,0 +1 @@ +from ._linear import LinearSelector diff --git a/fastplotlib/graphics/selectors/linear.py b/fastplotlib/graphics/selectors/_linear.py similarity index 95% rename from fastplotlib/graphics/selectors/linear.py rename to fastplotlib/graphics/selectors/_linear.py index d9799c8cf..0d90b8a6f 100644 --- a/fastplotlib/graphics/selectors/linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -36,10 +36,12 @@ ]) -class LinearBoundsFeature(GraphicFeature): +class _LinearBoundsFeature(GraphicFeature): """Feature for a linear bounding region""" def __init__(self, parent, bounds: Tuple[int, int]): - super(LinearBoundsFeature, self).__init__(parent, data=bounds) + # int so we can use these as slice indices for other purposes + bounds = tuple(map(int, bounds)) + super(_LinearBoundsFeature, self).__init__(parent, data=bounds) def _set(self, value): # sets new bounds @@ -49,6 +51,9 @@ def _set(self, value): "where `min_bound` and `max_bound` are numeric values." ) + # int so we can use these as slice indices for other purposes + value = tuple(map(int, value)) + # change left x position of the fill mesh self._parent.fill.geometry.positions.data[x_left, 0] = value[0] @@ -86,7 +91,6 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): class LinearSelector(Graphic, Interaction): - """Linear region selector, for lines or line collections.""" feature_events = ( "bounds" ) @@ -105,7 +109,7 @@ def __init__( ): """ Create a LinearSelector graphic which can be moved only along the x-axis. Useful for sub-selecting - data Line graphics or Heatmap graphics. + data on Line graphics. bounds[0], limits[0], and position[0] must be identical @@ -205,13 +209,10 @@ def __init__( partial(self._pointer_enter_edge, edge_line), "pointer_enter" ) - edge_line.add_event_handler( - partial(self._pointer_leave_edge), - "pointer_leave" - ) + edge_line.add_event_handler(self._pointer_leave_edge, "pointer_leave") # set the initial bounds of the selector - self.bounds = LinearBoundsFeature(self, bounds) + self.bounds = _LinearBoundsFeature(self, bounds) self.bounds = bounds def get_selected_data(self) -> Union[np.ndarray, List[np.ndarray]]: From a71f01ea1c70398ade49ea35dfafe3affc76f26f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 15 Apr 2023 04:43:24 -0400 Subject: [PATCH 09/25] allow using get_selected_data() from another graphic --- fastplotlib/graphics/selectors/_linear.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 0d90b8a6f..c2cb017b3 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -215,7 +215,7 @@ def __init__( self.bounds = _LinearBoundsFeature(self, bounds) self.bounds = bounds - def get_selected_data(self) -> Union[np.ndarray, List[np.ndarray]]: + def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[np.ndarray]]: """ Get the ``Graphic`` data bounded by the current selection. Returns a view of the full data array. @@ -227,19 +227,27 @@ def get_selected_data(self) -> Union[np.ndarray, List[np.ndarray]]: view or list of views of the full array """ - if self.parent is None: - raise AttributeError("No parent Graphic associated with selector") + 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." + ) + + if graphic is not None: + source = graphic + else: + source = self.parent # slice along x-axis x_slice = slice(*self.bounds()) - if isinstance(self.parent, GraphicCollection): + 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 - return self.parent[:].data[x_slice] + return source[:].data[x_slice] - return self.parent.data.buffer.data[x_slice] + return source.data.buffer.data[x_slice] def _add_plot_area_hook(self, plot_area): # called when this selector is added to a plot area From 971f5e8929e127a8b09812892e02f623de9d7c89 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 15 Apr 2023 04:48:30 -0400 Subject: [PATCH 10/25] update docstring --- fastplotlib/graphics/selectors/_linear.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index c2cb017b3..4722c9e4d 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -220,9 +220,13 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n Get the ``Graphic`` data bounded by the current selection. Returns a view of the full data array. If the ``Graphic`` is a collection, such as a ``LineStack``, it returns a list of views of the full array. + Can be performed on the ``parent`` Graphic or on another graphic by passing to the ``graphic`` arg. Returns ------- + graphic: Graphic, optional + if provided, returns the data selection from this graphic instead of the graphic set as ``parent`` + Union[np.ndarray, List[np.ndarray]] view or list of views of the full array From 5628814b60faeee1bd311aef60602cf384d6daee Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 15 Apr 2023 18:03:44 -0400 Subject: [PATCH 11/25] linearselector compensates for graphic worldobject position, rename height to size --- fastplotlib/graphics/line.py | 2 +- fastplotlib/graphics/selectors/_linear.py | 30 ++++++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 6844ff327..b7710824e 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -134,7 +134,7 @@ def add_linear_selector(self, padding: float = 100.0, **kwargs): selector = LinearSelector( bounds=bounds_init, limits=x_limits, - height=height, + size=height, position=position, parent=self, **kwargs diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 4722c9e4d..60eb40897 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -9,7 +9,7 @@ from ..features._base import GraphicFeature, FeatureEvent -# positions for indexing the BoxGeometry to set the "width" and "height" of the box +# 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, @@ -99,8 +99,9 @@ def __init__( self, bounds: Tuple[int, int], limits: Tuple[int, int], - height: int, + size: int, position: Tuple[int, int], + axis: str = "x", parent: Graphic = None, resizable: bool = False, fill_color=(0, 0, 0.35), @@ -121,12 +122,18 @@ def __init__( limits: (int, int) (min limit, max limit) for the selector - height: int + size: int height of the selector position: (int, int) initial position of the selector + axis: str, default "x" + "x" | "y", axis for the selector + + parent: Graphic, default ``None`` + associated this selector with a parent Graphic + resizable: bool if ``True``, the edges can be dragged to resize the width of the linear selection @@ -155,7 +162,7 @@ def __init__( # the fill of the selection self.fill = pygfx.Mesh( - pygfx.box_geometry(1, height, 1), + pygfx.box_geometry(1, size, 1), pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)) ) @@ -177,8 +184,8 @@ def __init__( # position data for the left edge line left_line_data = np.array( - [[position[0], (-height / 2) + position[1], 0.5], - [position[0], (height / 2) + position[1], 0.5]] + [[position[0], (-size / 2) + position[1], 0.5], + [position[0], (size / 2) + position[1], 0.5]] ).astype(np.float32) left_line = pygfx.Line( @@ -188,8 +195,8 @@ def __init__( # position data for the right edge line right_line_data = np.array( - [[bounds[1], (-height / 2) + position[1], 0.5], - [bounds[1], (height / 2) + position[1], 0.5]] + [[bounds[1], (-size / 2) + position[1], 0.5], + [bounds[1], (size / 2) + position[1], 0.5]] ).astype(np.float32) right_line = pygfx.Line( @@ -237,13 +244,18 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n "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 + # if the graphic position is not at (0, 0) then the bounds must be offset + offset = source.position.x + offset_bounds = (v - offset for v in self.bounds()) + # slice along x-axis - x_slice = slice(*self.bounds()) + x_slice = slice(*offset_bounds) if isinstance(source, GraphicCollection): # this will return a list of views of the arrays, therefore no copy operations occur From 485dd1551a5adbcdd36a5cdaf208f8739f81a623 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 16 Apr 2023 00:27:40 -0400 Subject: [PATCH 12/25] fill and edges properly track mouse with movements --- fastplotlib/graphics/selectors/_linear.py | 45 +++++++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 60eb40897..f2b79af4b 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -52,7 +52,7 @@ def _set(self, value): ) # int so we can use these as slice indices for other purposes - value = tuple(map(int, value)) + # value = tuple(map(int, value)) # change left x position of the fill mesh self._parent.fill.geometry.positions.data[x_left, 0] = value[0] @@ -66,7 +66,7 @@ def _set(self, value): # change position of the right edge line self._parent.edges[1].geometry.positions.data[:, 0] = value[1] - self._data = (value[0], value[1]) + self._data = value#(value[0], value[1]) # send changes to GPU self._parent.fill.geometry.positions.update_range() @@ -253,6 +253,8 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n # if the graphic position is not at (0, 0) then the bounds must be offset offset = source.position.x offset_bounds = (v - offset for v in self.bounds()) + # need them to be int to use as indices + offset_bounds = tuple(map(int, offset_bounds)) # slice along x-axis x_slice = slice(*offset_bounds) @@ -293,7 +295,7 @@ def _move_start(self, event_source: str, ev): """ # self._plot_area.controller.enabled = False # last pointer position - self._move_info = {"last_pos": (ev.x, ev.y)} + self._move_info = {"last_pos": (np.int64(ev.x), np.int64(ev.y))} self._event_source = event_source def _move(self, ev): @@ -306,26 +308,53 @@ def _move(self, ev): last = self._move_info["last_pos"] - delta = (last[0] - ev.x, last[1] - ev.y) + # new - last + # pointer move events are in viewport or canvas space + delta = Vector3(ev.x - last[0], ev.y - last[1]) self._move_info = {"last_pos": (ev.x, ev.y)} + viewport_size = self._plot_area.viewport.logical_size + + # convert delta to NDC coordinates using viewport size + delta_ndc = delta.multiply( + Vector3( + 2 / viewport_size[0], + -2 / viewport_size[1], + 0 + ) + ) + + camera = self._plot_area.camera + + # left bound current world position + left_vec = Vector3(self.bounds()[0]) + # compute and add delta in projected NDC space and then unproject back to world space + left_vec.project(camera).add(delta_ndc).unproject(camera) + + # left bound current world position + right_vec = Vector3(self.bounds()[1]) + # compute and add delta in projected NDC space and then unproject back to world space + right_vec.project(camera).add(delta_ndc).unproject(camera) + + print(right_vec) + if self._event_source == "edge-left": # change only the left bound # move the left edge only, expand the fill in the leftward direction - left_bound = self.bounds()[0] - delta[0] + left_bound = left_vec.x right_bound = self.bounds()[1] elif self._event_source == "edge-right": # change only the right bound # move the right edge only, expand the fill in the rightward direction left_bound = self.bounds()[0] - right_bound = self.bounds()[1] - delta[0] + right_bound = right_vec.x elif self._event_source == "fill": # move the entire selector - left_bound = self.bounds()[0] - delta[0] - right_bound = self.bounds()[1] - delta[0] + left_bound = left_vec.x + right_bound = right_vec.x # if the limits are met do nothing if left_bound < self.limits[0] or right_bound > self.limits[1]: From 062a63ae0be2be771c13990057bc13c8d7c902ac Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 16 Apr 2023 01:37:07 -0400 Subject: [PATCH 13/25] del print --- fastplotlib/graphics/selectors/_linear.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index f2b79af4b..b4eef66b6 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -337,8 +337,6 @@ def _move(self, ev): # compute and add delta in projected NDC space and then unproject back to world space right_vec.project(camera).add(delta_ndc).unproject(camera) - print(right_vec) - if self._event_source == "edge-left": # change only the left bound # move the left edge only, expand the fill in the leftward direction From 146a30e5d444cdd1fd4c8d73572de73d4894ddf2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 16 Apr 2023 05:22:11 -0400 Subject: [PATCH 14/25] line selector works on y axis too --- fastplotlib/graphics/line.py | 39 +++- fastplotlib/graphics/selectors/_linear.py | 235 +++++++++++++++------- 2 files changed, 192 insertions(+), 82 deletions(-) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index b7710824e..fab176cd7 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -117,24 +117,41 @@ def add_linear_selector(self, padding: float = 100.0, **kwargs): linear selection graphic """ data = self.data() - # x limits - x_limits = (data[0, 0], data[-1, 0]) - # initial bounds are 20% of the limits range - bounds_init = (x_limits[0], int(np.ptp(x_limits) * 0.2)) + if "axis" in kwargs.keys(): + axis = kwargs["axis"] + else: + axis = "x" + + if axis == "x": + # x limits + limits = (data[0, 0], data[-1, 0]) + + # height + padding + size = np.ptp(data[:, 1]) + padding - # width of the y-vals + padding - height = np.ptp(data[:, 1]) + padding + # initial position of the selector + position_y = (data[:, 1].min() + data[:, 1].max()) / 2 + position = (limits[0], position_y) + else: + # y limits + limits = (data[0, 1], data[-1, 1]) + + # width + padding + size = np.ptp(data[:, 0]) + padding - # initial position of the selector - position_y = (data[:, 1].min() + data[:, 1].max()) / 2 - position = (x_limits[0], position_y) + # initial position of the selector + position_x = (data[:, 0].min() + data[:, 0].max()) / 2 + position = (position_x, limits[0]) + + # initial bounds are 20% of the limits range + bounds_init = (limits[0], int(np.ptp(limits) * 0.2)) # create selector selector = LinearSelector( bounds=bounds_init, - limits=x_limits, - size=height, + limits=limits, + size=size, position=position, parent=self, **kwargs diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index b4eef66b6..e5eec31ce 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -38,11 +38,18 @@ class _LinearBoundsFeature(GraphicFeature): """Feature for a linear bounding region""" - def __init__(self, parent, bounds: Tuple[int, int]): + def __init__(self, parent, bounds: Tuple[int, int], axis: str): # int so we can use these as slice indices for other purposes bounds = tuple(map(int, bounds)) super(_LinearBoundsFeature, self).__init__(parent, data=bounds) + self._axis = axis + + @property + def axis(self) -> str: + """one of "x" | "y" """ + return self._axis + def _set(self, value): # sets new bounds if not isinstance(value, tuple): @@ -54,17 +61,31 @@ def _set(self, value): # int so we can use these as slice indices for other purposes # value = tuple(map(int, value)) - # change left x position of the fill mesh - self._parent.fill.geometry.positions.data[x_left, 0] = value[0] + if self.axis == "x": + # change left x position of the fill mesh + self._parent.fill.geometry.positions.data[x_left, 0] = value[0] + + # change right x position of the fill mesh + self._parent.fill.geometry.positions.data[x_right, 0] = value[1] + + # change x position of the left edge line + self._parent.edges[0].geometry.positions.data[:, 0] = value[0] + + # change x position of the right edge line + self._parent.edges[1].geometry.positions.data[:, 0] = value[1] + + elif self.axis == "y": + # change bottom y position of the fill mesh + self._parent.fill.geometry.positions.data[y_bottom, 1] = value[0] - # change right x position of the fill mesh - self._parent.fill.geometry.positions.data[x_right, 0] = value[1] + # change top position of the fill mesh + self._parent.fill.geometry.positions.data[y_top, 1] = value[1] - # change position of the left edge line - self._parent.edges[0].geometry.positions.data[:, 0] = value[0] + # change y position of the bottom edge line + self._parent.edges[0].geometry.positions.data[:, 1] = value[0] - # change position of the right edge line - self._parent.edges[1].geometry.positions.data[:, 0] = value[1] + # change y position of the top edge line + self._parent.edges[1].geometry.positions.data[:, 1] = value[1] self._data = value#(value[0], value[1]) @@ -147,8 +168,16 @@ def __init__( 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)) + position = tuple(map(round, position)) + if limits[0] != position[0] != bounds[0]: - raise ValueError("limits[0] != position[0] != bounds[0]") + raise ValueError( + f"limits[0] != position[0] != bounds[0]\n" + f"{limits[0]} != {position[0]} != {bounds[0]}" + ) super(LinearSelector, self).__init__(name=name) @@ -160,11 +189,20 @@ def __init__( group = pygfx.Group() self._set_world_object(group) - # the fill of the selection - self.fill = pygfx.Mesh( + if axis == "x": + mesh = pygfx.Mesh( pygfx.box_geometry(1, size, 1), pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)) - ) + ) + + elif axis == "y": + mesh = pygfx.Mesh( + pygfx.box_geometry(size, 1, 1), + pygfx.MeshBasicMaterial(color=pygfx.Color(fill_color)) + ) + + # the fill of the selection + self.fill = mesh self.fill.position.set(*position, -2) @@ -182,33 +220,60 @@ def __init__( self._edge_color = np.repeat([pygfx.Color(edge_color)], 2, axis=0) - # position data for the left edge line - left_line_data = np.array( - [[position[0], (-size / 2) + position[1], 0.5], - [position[0], (size / 2) + position[1], 0.5]] - ).astype(np.float32) + if axis == "x": + # position data for the left edge line + left_line_data = np.array( + [[position[0], (-size / 2) + position[1], 0.5], + [position[0], (size / 2) + position[1], 0.5]] + ).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) - ) + left_line = pygfx.Line( + pygfx.Geometry(positions=left_line_data, colors=self._edge_color.copy()), + pygfx.LineMaterial(thickness=3, vertex_colors=True) + ) - # position data for the right edge line - right_line_data = np.array( - [[bounds[1], (-size / 2) + position[1], 0.5], - [bounds[1], (size / 2) + position[1], 0.5]] - ).astype(np.float32) + # position data for the right edge line + right_line_data = np.array( + [[bounds[1], (-size / 2) + position[1], 0.5], + [bounds[1], (size / 2) + position[1], 0.5]] + ).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) - ) + right_line = pygfx.Line( + pygfx.Geometry(positions=right_line_data, colors=self._edge_color.copy()), + pygfx.LineMaterial(thickness=3, vertex_colors=True) + ) - # add the edge lines - self.world_object.add(left_line) - self.world_object.add(right_line) + self.edges: Tuple[pygfx.Line, pygfx.Line] = (left_line, right_line) + + elif axis == "y": + # position data for the left edge line + bottom_line_data = \ + np.array( + [[(-size / 2) + position[0], position[1], 0.5], + [ (size / 2) + position[0], position[1], 0.5]] + ).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) + ) + + # position data for the right edge line + top_line_data = np.array( + [[(-size / 2) + position[0], bounds[1], 0.5], + [ (size / 2) + position[0], bounds[1], 0.5]] + ).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) + ) - self.edges: Tuple[pygfx.Line, pygfx.Line] = (left_line, right_line) + self.edges: Tuple[pygfx.Line, pygfx.Line] = (bottom_line, top_line) + + # add the edge lines + for edge in self.edges: + self.world_object.add(edge) # highlight the edges when mouse is hovered for edge_line in self.edges: @@ -219,8 +284,8 @@ def __init__( edge_line.add_event_handler(self._pointer_leave_edge, "pointer_leave") # set the initial bounds of the selector - self.bounds = _LinearBoundsFeature(self, bounds) - self.bounds = bounds + self.bounds = _LinearBoundsFeature(self, bounds, axis=axis) + self.bounds: _LinearBoundsFeature = bounds def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[np.ndarray]]: """ @@ -229,11 +294,18 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n If the ``Graphic`` is a collection, such as a ``LineStack``, it returns a list of views of the full array. Can be performed on the ``parent`` Graphic or on another graphic by passing to the ``graphic`` arg. - Returns - ------- + **NOTE:** You must be aware of the axis for the selector. The sub-selected data that is returned will be of + shape ``[n_points_selected, 3]``. If you have selected along the x-axis then you can access y-values of the + subselection like this: sub[:, 1]. Conversely, if you have selected along the y-axis then you can access the + x-values of the subselection like this: sub[:, 0]. + + Parameters + ---------- graphic: Graphic, optional if provided, returns the data selection from this graphic instead of the graphic set as ``parent`` + Returns + ------- Union[np.ndarray, List[np.ndarray]] view or list of views of the full array @@ -251,21 +323,32 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n source = self.parent # if the graphic position is not at (0, 0) then the bounds must be offset - offset = source.position.x - offset_bounds = (v - offset for v in self.bounds()) + 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)) - # slice along x-axis - x_slice = slice(*offset_bounds) + if self.bounds.axis == "x": + dim = 0 + else: + dim = 1 + # now we need to map from graphic space to data space + ixs = np.where( + (source.data()[:, dim] >= offset_bounds[0]) & (source.data()[:, dim] <= offset_bounds[1]) + )[0] + + print(ixs) + + s = slice(ixs[0], ixs[-1]) 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 - return source[:].data[x_slice] - return source.data.buffer.data[x_slice] + return source[:].data[s] + + return source.data.buffer.data[s] def _add_plot_area_hook(self, plot_area): # called when this selector is added to a plot area @@ -273,14 +356,14 @@ def _add_plot_area_hook(self, plot_area): # need partials so that the source of the event is passed to the `_move_start` handler move_start_fill = partial(self._move_start, "fill") - move_start_edge_left = partial(self._move_start, "edge-left") - move_start_edge_right = partial(self._move_start, "edge-right") + move_start_edge_0 = partial(self._move_start, "edge-0") + move_start_edge_1 = partial(self._move_start, "edge-1") self.fill.add_event_handler(move_start_fill, "pointer_down") if self._resizable: - self.edges[0].add_event_handler(move_start_edge_left, "pointer_down") - self.edges[1].add_event_handler(move_start_edge_right, "pointer_down") + self.edges[0].add_event_handler(move_start_edge_0, "pointer_down") + self.edges[1].add_event_handler(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") @@ -295,7 +378,7 @@ def _move_start(self, event_source: str, ev): """ # self._plot_area.controller.enabled = False # last pointer position - self._move_info = {"last_pos": (np.int64(ev.x), np.int64(ev.y))} + self._move_info = {"last_pos": (ev.x, ev.y)} self._event_source = event_source def _move(self, ev): @@ -317,6 +400,7 @@ def _move(self, ev): 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], @@ -327,44 +411,53 @@ def _move(self, ev): camera = self._plot_area.camera - # left bound current world position - left_vec = Vector3(self.bounds()[0]) + # 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 - left_vec.project(camera).add(delta_ndc).unproject(camera) + vec0.project(camera).add(delta_ndc).unproject(camera) - # left bound current world position - right_vec = Vector3(self.bounds()[1]) + # edge-1 bound current world position + if self.bounds.axis == "x": + vec1 = Vector3(self.bounds()[1]) + else: + vec1 = Vector3(0, self.bounds()[1]) # compute and add delta in projected NDC space and then unproject back to world space - right_vec.project(camera).add(delta_ndc).unproject(camera) + vec1.project(camera).add(delta_ndc).unproject(camera) - if self._event_source == "edge-left": - # change only the left bound - # move the left edge only, expand the fill in the leftward direction - left_bound = left_vec.x - right_bound = self.bounds()[1] + 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 + bound1 = self.bounds()[1] - elif self._event_source == "edge-right": - # change only the right bound - # move the right edge only, expand the fill in the rightward direction - left_bound = self.bounds()[0] - right_bound = right_vec.x + elif self._event_source == "edge-1": + # change only the right bound 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 - left_bound = left_vec.x - right_bound = right_vec.x + bound0 = getattr(vec0, self.bounds.axis) + bound1 = getattr(vec1, self.bounds.axis) + + # print(bound0, bound1) # if the limits are met do nothing - if left_bound < self.limits[0] or right_bound > self.limits[1]: + 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 (right_bound - left_bound) < 2: + if (bound1 - bound0) < 2: return # set the new bounds - self.bounds = (left_bound, right_bound) + self.bounds = (bound0, bound1) # re-enable the controller self._plot_area.controller.enabled = True From 495a3b60427afe2d26907af350e2f43b605adc5b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 16 Apr 2023 05:23:20 -0400 Subject: [PATCH 15/25] remove prints --- fastplotlib/graphics/selectors/_linear.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index e5eec31ce..2c7e40180 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -337,8 +337,6 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n (source.data()[:, dim] >= offset_bounds[0]) & (source.data()[:, dim] <= offset_bounds[1]) )[0] - print(ixs) - s = slice(ixs[0], ixs[-1]) if isinstance(source, GraphicCollection): @@ -444,8 +442,6 @@ def _move(self, ev): bound0 = getattr(vec0, self.bounds.axis) bound1 = getattr(vec1, self.bounds.axis) - # print(bound0, bound1) - # if the limits are met do nothing if bound0 < self.limits[0] or bound1 > self.limits[1]: return From 2ef80dd4828af6fc5e81066122113a18e3fdd6f1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 16 Apr 2023 05:54:38 -0400 Subject: [PATCH 16/25] fix position checks for y --- fastplotlib/graphics/selectors/_linear.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 2c7e40180..89ae6c1c7 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -173,11 +173,20 @@ def __init__( limits = tuple(map(round, limits)) position = tuple(map(round, position)) - if limits[0] != position[0] != bounds[0]: - raise ValueError( - f"limits[0] != position[0] != bounds[0]\n" - f"{limits[0]} != {position[0]} != {bounds[0]}" - ) + if axis == "x": + if limits[0] != position[0] != bounds[0]: + raise ValueError( + f"limits[0] != position[0] != bounds[0]\n" + f"{limits[0]} != {position[0]} != {bounds[0]}" + ) + + elif axis == "y": + # initial y-position is position[1] + if limits[0] != position[1] != bounds[0]: + raise ValueError( + f"limits[0] != position[1] != bounds[0]\n" + f"{limits[0]} != {position[1]} != {bounds[0]}" + ) super(LinearSelector, self).__init__(name=name) From 9abe206fa8d7a7de7977e97747455c137e32a538 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 16 Apr 2023 06:27:29 -0400 Subject: [PATCH 17/25] attempt at gc for LinearSelector, will do later --- fastplotlib/graphics/line.py | 19 ++++++++---------- fastplotlib/graphics/selectors/_linear.py | 24 ++++++++++++++++------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index fab176cd7..30968a1ca 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -1,4 +1,6 @@ from typing import * +import weakref + import numpy as np import pygfx @@ -97,11 +99,10 @@ def __init__( if z_position is not None: self.world_object.position.z = z_position - self.selectors: List[LinearSelector] = list() - - def add_linear_selector(self, padding: float = 100.0, **kwargs): + def add_linear_selector(self, padding: float = 100.0, **kwargs) -> LinearSelector: """ - Add a ``LinearSelector``. + Add a ``LinearSelector``. Selectors are just ``Graphic`` objects so you can manage, remove, or delete them + from a plot area just like any other ``Graphic``. Parameters ---------- @@ -160,14 +161,10 @@ def add_linear_selector(self, padding: float = 100.0, **kwargs): self._plot_area.add_graphic(selector, center=False) # so that it is below this graphic selector.position.set_z(self.position.z - 1) - - self.selectors.append(selector) - - return selector - def remove_selector(self, selector: LinearSelector): - self.selectors.remove(selector) - self._plot_area.delete_graphic(selector) + # 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) def _add_plot_area_hook(self, plot_area): self._plot_area = plot_area diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 89ae6c1c7..e346fc450 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -362,15 +362,15 @@ def _add_plot_area_hook(self, plot_area): self._plot_area = plot_area # need partials so that the source of the event is passed to the `_move_start` handler - move_start_fill = partial(self._move_start, "fill") - move_start_edge_0 = partial(self._move_start, "edge-0") - move_start_edge_1 = partial(self._move_start, "edge-1") + 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(move_start_fill, "pointer_down") + self.fill.add_event_handler(self._move_start_fill, "pointer_down") if self._resizable: - self.edges[0].add_event_handler(move_start_edge_0, "pointer_down") - self.edges[1].add_event_handler(move_start_edge_1, "pointer_down") + 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") @@ -495,4 +495,14 @@ def _set_feature(self, feature: str, new_data: Any, indices: Any): pass def _reset_feature(self, feature: str): - pass \ No newline at end of file + 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") From beaf442f1755a9cc9f3258909ee663b5df8d5a83 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 16 Apr 2023 18:29:13 -0400 Subject: [PATCH 18/25] add get_selected_indices() --- fastplotlib/graphics/selectors/_linear.py | 68 +++++++++++++++-------- 1 file changed, 46 insertions(+), 22 deletions(-) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index e346fc450..4c9a8f1a0 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -39,8 +39,6 @@ class _LinearBoundsFeature(GraphicFeature): """Feature for a linear bounding region""" def __init__(self, parent, bounds: Tuple[int, int], axis: str): - # int so we can use these as slice indices for other purposes - bounds = tuple(map(int, bounds)) super(_LinearBoundsFeature, self).__init__(parent, data=bounds) self._axis = axis @@ -58,9 +56,6 @@ def _set(self, value): "where `min_bound` and `max_bound` are numeric values." ) - # int so we can use these as slice indices for other purposes - # value = tuple(map(int, value)) - if self.axis == "x": # change left x position of the fill mesh self._parent.fill.geometry.positions.data[x_left, 0] = value[0] @@ -319,17 +314,40 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n view or list of views of the full array """ - 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." - ) + source = self._get_source(graphic) + ixs = self.get_selected_indices(source) - # use passed graphic if provided, else use parent - if graphic is not None: - source = graphic - else: - source = self.parent + s = slice(ixs[0], ixs[-1]) + + 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 + return source[:].data[s] + + return source.data.buffer.data[s] + + def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, List[np.ndarray]]: + """ + Returns the indices of the ``Graphic`` data bounded by the current selection. + This is useful because the ``bounds`` min and max are not necessarily the same + 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``. + + Parameters + ---------- + graphic: Graphic, optional + if provided, returns the selection indices from this graphic instead of the graphic set as ``parent`` + + Returns + ------- + Union[np.ndarray, List[np.ndarray]] + data indices of the selection + + """ + source = self._get_source(graphic) # if the graphic position is not at (0, 0) then the bounds must be offset offset = getattr(source.position, self.bounds.axis) @@ -346,16 +364,22 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n (source.data()[:, dim] >= offset_bounds[0]) & (source.data()[:, dim] <= offset_bounds[1]) )[0] - s = slice(ixs[0], ixs[-1]) + return ixs - 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 + 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." + ) - return source[:].data[s] + # use passed graphic if provided, else use parent + if graphic is not None: + source = graphic + else: + source = self.parent - return source.data.buffer.data[s] + return source def _add_plot_area_hook(self, plot_area): # called when this selector is added to a plot area From 5b7bcbba519609148bb2d1e344ec4f7201ba2f88 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 00:26:36 -0400 Subject: [PATCH 19/25] properly compensate for graphics with position offset from 0, 0 --- fastplotlib/graphics/line.py | 59 ++++++---- fastplotlib/graphics/selectors/_linear.py | 125 ++++++++++++++-------- 2 files changed, 116 insertions(+), 68 deletions(-) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 30968a1ca..4732d4339 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -101,7 +101,7 @@ def __init__( def add_linear_selector(self, padding: float = 100.0, **kwargs) -> LinearSelector: """ - Add a ``LinearSelector``. Selectors are just ``Graphic`` objects so you can manage, remove, or delete them + Add a ``LinearSelector``. Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a plot area just like any other ``Graphic``. Parameters @@ -116,7 +116,30 @@ def add_linear_selector(self, padding: float = 100.0, **kwargs) -> LinearSelecto ------- LinearSelector linear selection graphic + """ + + bounds_init, limits, size, origin = self._get_linear_selector_init_args(padding, **kwargs) + + # create selector + selector = LinearSelector( + bounds=bounds_init, + limits=limits, + size=size, + origin=origin, + parent=self, + **kwargs + ) + + self._plot_area.add_graphic(selector, center=False) + # so that it is below this graphic + selector.position.set_z(self.position.z - 1) + + # 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) + + def _get_linear_selector_init_args(self, padding: float, **kwargs): data = self.data() if "axis" in kwargs.keys(): @@ -125,46 +148,36 @@ def add_linear_selector(self, padding: float = 100.0, **kwargs) -> LinearSelecto axis = "x" if axis == "x": + offset = self.position.x # x limits - limits = (data[0, 0], data[-1, 0]) + limits = (data[0, 0] + offset, data[-1, 0] + offset) # height + padding size = np.ptp(data[:, 1]) + padding # initial position of the selector position_y = (data[:, 1].min() + data[:, 1].max()) / 2 - position = (limits[0], position_y) + + # need y offset too for this + origin = (limits[0] - offset, position_y + self.position.y) else: + offset = self.position.y # y limits - limits = (data[0, 1], data[-1, 1]) + limits = (data[0, 1] + offset, data[-1, 1] + offset) # width + padding size = np.ptp(data[:, 0]) + padding # initial position of the selector position_x = (data[:, 0].min() + data[:, 0].max()) / 2 - position = (position_x, limits[0]) - # initial bounds are 20% of the limits range - bounds_init = (limits[0], int(np.ptp(limits) * 0.2)) + # need x offset too for this + origin = (position_x + self.position.x, limits[0] - offset) - # create selector - selector = LinearSelector( - bounds=bounds_init, - limits=limits, - size=size, - position=position, - parent=self, - **kwargs - ) - - self._plot_area.add_graphic(selector, center=False) - # so that it is below this graphic - selector.position.set_z(self.position.z - 1) + # initial bounds are 20% of the limits range + bounds_init = (limits[0], int(np.ptp(limits) * 0.2) + offset) - # 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) + return bounds_init, limits, size, origin def _add_plot_area_hook(self, plot_area): self._plot_area = plot_area diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 4c9a8f1a0..9d717ea0c 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -10,7 +10,7 @@ # 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 +# 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, @@ -36,10 +36,10 @@ ]) -class _LinearBoundsFeature(GraphicFeature): +class LinearBoundsFeature(GraphicFeature): """Feature for a linear bounding region""" def __init__(self, parent, bounds: Tuple[int, int], axis: str): - super(_LinearBoundsFeature, self).__init__(parent, data=bounds) + super(LinearBoundsFeature, self).__init__(parent, data=bounds) self._axis = axis @@ -116,10 +116,10 @@ def __init__( bounds: Tuple[int, int], limits: Tuple[int, int], size: int, - position: Tuple[int, int], + origin: Tuple[int, int], axis: str = "x", parent: Graphic = None, - resizable: bool = False, + resizable: bool = True, fill_color=(0, 0, 0.35), edge_color=(0.8, 0.8, 0), name: str = None @@ -139,9 +139,9 @@ def __init__( (min limit, max limit) for the selector size: int - height of the selector + height or width of the selector - position: (int, int) + origin: (int, int) initial position of the selector axis: str, default "x" @@ -166,22 +166,24 @@ def __init__( # lots of very close to zero values etc. so round them bounds = tuple(map(round, bounds)) limits = tuple(map(round, limits)) - position = tuple(map(round, position)) - - if axis == "x": - if limits[0] != position[0] != bounds[0]: - raise ValueError( - f"limits[0] != position[0] != bounds[0]\n" - f"{limits[0]} != {position[0]} != {bounds[0]}" - ) - - elif axis == "y": - # initial y-position is position[1] - if limits[0] != position[1] != bounds[0]: - raise ValueError( - f"limits[0] != position[1] != bounds[0]\n" - f"{limits[0]} != {position[1]} != {bounds[0]}" - ) + origin = tuple(map(round, origin)) + + # TODO: sanity checks, we recommend users to add LinearSelection using the add_linear_selector() methods + # TODO: so we can worry about the sanity checks later + # if axis == "x": + # if limits[0] != origin[0] != bounds[0]: + # raise ValueError( + # f"limits[0] != position[0] != bounds[0]\n" + # f"{limits[0]} != {origin[0]} != {bounds[0]}" + # ) + # + # elif axis == "y": + # # initial y-position is position[1] + # if limits[0] != origin[1] != bounds[0]: + # raise ValueError( + # f"limits[0] != position[1] != bounds[0]\n" + # f"{limits[0]} != {origin[1]} != {bounds[0]}" + # ) super(LinearSelector, self).__init__(name=name) @@ -208,7 +210,7 @@ def __init__( # the fill of the selection self.fill = mesh - self.fill.position.set(*position, -2) + self.fill.position.set(*origin, -2) self.world_object.add(self.fill) @@ -227,8 +229,8 @@ def __init__( if axis == "x": # position data for the left edge line left_line_data = np.array( - [[position[0], (-size / 2) + position[1], 0.5], - [position[0], (size / 2) + position[1], 0.5]] + [[origin[0], (-size / 2) + origin[1], 0.5], + [origin[0], (size / 2) + origin[1], 0.5]] ).astype(np.float32) left_line = pygfx.Line( @@ -238,8 +240,8 @@ def __init__( # position data for the right edge line right_line_data = np.array( - [[bounds[1], (-size / 2) + position[1], 0.5], - [bounds[1], (size / 2) + position[1], 0.5]] + [[bounds[1], (-size / 2) + origin[1], 0.5], + [bounds[1], (size / 2) + origin[1], 0.5]] ).astype(np.float32) right_line = pygfx.Line( @@ -253,8 +255,8 @@ def __init__( # position data for the left edge line bottom_line_data = \ np.array( - [[(-size / 2) + position[0], position[1], 0.5], - [ (size / 2) + position[0], position[1], 0.5]] + [[(-size / 2) + origin[0], origin[1], 0.5], + [(size / 2) + origin[0], origin[1], 0.5]] ).astype(np.float32) bottom_line = pygfx.Line( @@ -264,8 +266,8 @@ def __init__( # position data for the right edge line top_line_data = np.array( - [[(-size / 2) + position[0], bounds[1], 0.5], - [ (size / 2) + position[0], bounds[1], 0.5]] + [[(-size / 2) + origin[0], bounds[1], 0.5], + [(size / 2) + origin[0], bounds[1], 0.5]] ).astype(np.float32) top_line = pygfx.Line( @@ -277,6 +279,7 @@ def __init__( # 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 @@ -288,10 +291,19 @@ def __init__( 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) + self._bounds: LinearBoundsFeature = bounds + + @property + def bounds(self) -> LinearBoundsFeature: + """ + 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 - def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[np.ndarray]]: + def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[np.ndarray], None]: """ Get the ``Graphic`` data bounded by the current selection. Returns a view of the full data array. @@ -310,22 +322,34 @@ def get_selected_data(self, graphic: Graphic = None) -> Union[np.ndarray, List[n Returns ------- - Union[np.ndarray, List[np.ndarray]] - view or list of views of the full array + np.ndarray, List[np.ndarray], or None + view or list of views of the full array, returns ``None`` if selection is empty """ source = self._get_source(graphic) ixs = self.get_selected_indices(source) - s = slice(ixs[0], ixs[-1]) - 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 - return source.data.buffer.data[s] + s = slice(ixs[0], ixs[-1]) + return source.data.buffer.data[s] def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, List[np.ndarray]]: """ @@ -360,9 +384,20 @@ def get_selected_indices(self, graphic: Graphic = None) -> Union[np.ndarray, Lis else: dim = 1 # now we need to map from graphic space to data space - ixs = np.where( - (source.data()[:, dim] >= offset_bounds[0]) & (source.data()[:, dim] <= offset_bounds[1]) - )[0] + # 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 @@ -479,10 +514,10 @@ def _move(self, ev): if bound0 < self.limits[0] or bound1 > self.limits[1]: return - # make sure `selector width > 2`, left edge must not move past right edge! + # 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 (bound1 - bound0) < 2: + if not (bound1 - bound0) >= 2: return # set the new bounds From 6d35691e1392b0364c53e0775391caf24981246f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 00:55:00 -0400 Subject: [PATCH 20/25] linear selector works for line collections and line stack --- fastplotlib/graphics/line_collection.py | 88 ++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 3bff6f7c5..28e1fba19 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -1,11 +1,14 @@ +from typing import * +from copy import deepcopy +import weakref + import numpy as np import pygfx -from typing import * from ._base import Interaction, PreviouslyModifiedData, GraphicCollection from .line import LineGraphic +from .selectors import LinearSelector from ..utils import make_colors -from copy import deepcopy class LineCollection(GraphicCollection, Interaction): @@ -192,6 +195,87 @@ def __init__( self.add_graphic(lg, reset_index=False) + def add_linear_selector(self, padding: float = 100.0, **kwargs) -> LinearSelector: + """ + Add a ``LinearSelector``. 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 ``LinearSelector`` + + Returns + ------- + LinearSelector + linear selection graphic + + """ + + bounds_init = list() + limits = list() + sizes = list() + origin = list() + + for g in self.graphics: + _bounds_init, _limits, _size, _origin = g._get_linear_selector_init_args(padding=0, **kwargs) + bounds_init.append(_bounds_init) + limits.append(_limits) + sizes.append(_size) + origin.append(_origin) + + # set the init bounds using the extents of the collection + b = np.vstack(bounds_init) + bounds = (b[:, 0].min(), b[:, 1].max()) + + # set the limits using the extents of the collection + l = np.vstack(limits) + limits = (l[:, 0].min(), l[:, 1].max()) + + if isinstance(self, LineStack): + # sum them if it's a stack + size = sum(sizes) + else: + # just the biggest one if not stacked + size = max(sizes) + + size += padding + + # origin is the (min origin + max origin) / 2 + if "axis" in kwargs.keys(): + axis = kwargs["axis"] + else: + axis = "x" + + if axis == "x": + o = np.vstack(origin) + origin_y = (o[:, 1].min() + o[:, 1].max()) / 2 + origin = (limits[0], origin_y) + else: + o = np.vstack(origin) + origin_x = (o[:, 0].min() + o[:, 0].max()) / 2 + origin = (origin_x, limits[0]) + + selector = LinearSelector( + bounds=bounds, + limits=limits, + size=size, + origin=origin, + parent=self, + **kwargs + ) + + self._plot_area.add_graphic(selector, center=False) + selector.position.set_z(self.position.z - 1) + + return weakref.proxy(selector) + + def _add_plot_area_hook(self, plot_area): + self._plot_area = plot_area + def _set_feature(self, feature: str, new_data: Any, indices: Any): if not hasattr(self, "_previous_data"): self._previous_data = dict() From e078fc56b6c9de6575cbb89cd7dbb01bdc2aa48d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 01:27:26 -0400 Subject: [PATCH 21/25] add linestack separation to size --- fastplotlib/graphics/line_collection.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 28e1fba19..ec7881619 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -238,6 +238,7 @@ def add_linear_selector(self, padding: float = 100.0, **kwargs) -> LinearSelecto if isinstance(self, LineStack): # sum them if it's a stack size = sum(sizes) + size += self.separation * len(sizes) else: # just the biggest one if not stacked size = max(sizes) @@ -430,3 +431,5 @@ def __init__( for i, line in enumerate(self.graphics): getattr(line.position, f"set_{separation_axis}")(axis_zero) axis_zero = axis_zero + line.data()[:, axes[separation_axis]].max() + separation + + self.separation = separation From 033c4b19be3f5852bab48b91344e3c955768a962 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 01:30:36 -0400 Subject: [PATCH 22/25] add linear selector example nb --- examples/linear_selector.ipynb | 301 +++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 examples/linear_selector.ipynb diff --git a/examples/linear_selector.ipynb b/examples/linear_selector.ipynb new file mode 100644 index 000000000..d39709694 --- /dev/null +++ b/examples/linear_selector.ipynb @@ -0,0 +1,301 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "40bf515f-7ca3-4f16-8ec9-31076e8d4bde", + "metadata": {}, + "source": [ + "# `LinearSelector` with single lines" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41f4e1d0-9ae9-4e59-9883-d9339d985afe", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import fastplotlib as fpl\n", + "import numpy as np\n", + "\n", + "\n", + "gp = fpl.GridPlot((2, 2))\n", + "\n", + "# preallocated size for zoomed data\n", + "zoomed_prealloc = 1_000\n", + "\n", + "# data to plot\n", + "xs = np.linspace(0, 100, 1_000)\n", + "sine = np.sin(xs) * 20\n", + "\n", + "# make sine along x axis\n", + "sine_graphic_x = gp[0, 0].add_line(sine)\n", + "\n", + "# just something that looks different for line along y-axis\n", + "sine_y = sine\n", + "sine_y[sine_y > 0] = 0\n", + "\n", + "# sine along y axis\n", + "sine_graphic_y = gp[0, 1].add_line(np.column_stack([sine_y, xs]))\n", + "\n", + "# offset the position of the graphic to demonstrate `get_selected_data()` later\n", + "sine_graphic_y.position.set_x(50)\n", + "sine_graphic_y.position.set_y(50)\n", + "\n", + "# add linear selectors\n", + "ls_x = sine_graphic_x.add_linear_selector() # default axis is \"x\"\n", + "ls_y = sine_graphic_y.add_linear_selector(axis=\"y\")\n", + "\n", + "# preallocate array for storing zoomed in data\n", + "zoomed_init = np.column_stack([np.arange(zoomed_prealloc), np.random.rand(zoomed_prealloc)])\n", + "\n", + "# make line graphics for displaying zoomed data\n", + "zoomed_x = gp[1, 0].add_line(zoomed_init)\n", + "zoomed_y = gp[1, 1].add_line(zoomed_init)\n", + "\n", + "\n", + "def interpolate(subdata: np.ndarray, axis: int):\n", + " \"\"\"1D interpolation to display within the preallocated data array\"\"\"\n", + " x = np.arange(0, zoomed_prealloc)\n", + " xp = np.linspace(0, zoomed_prealloc, subdata.shape[0])\n", + " \n", + " # interpolate to preallocated size\n", + " return np.interp(x, xp, fp=subdata[:, axis]) # use the y-values\n", + "\n", + "\n", + "def set_zoom_x(ev):\n", + " \"\"\"sets zoomed x selector data\"\"\"\n", + " selected_data = ls_x.get_selected_data()\n", + " zoomed_x.data = interpolate(selected_data, axis=1) # use the y-values\n", + " gp[1, 0].auto_scale()\n", + "\n", + "\n", + "def set_zoom_y(ev):\n", + " \"\"\"sets zoomed y selector data\"\"\"\n", + " selected_data = ls_y.get_selected_data()\n", + " zoomed_y.data = -interpolate(selected_data, axis=0) # use the x-values\n", + " gp[1, 1].auto_scale()\n", + "\n", + "\n", + "# update zoomed plots when bounds change\n", + "ls_x.bounds.add_event_handler(set_zoom_x)\n", + "ls_y.bounds.add_event_handler(set_zoom_y)\n", + "\n", + "gp.show()" + ] + }, + { + "cell_type": "markdown", + "id": "66b1c599-42c0-4223-b33e-37c1ef077204", + "metadata": {}, + "source": [ + "### On the x-axis we have a 1-1 mapping from the data that we have passed and the line geometry positions. So the `bounds` min max corresponds directly to the data indices." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8b26a37d-aa1d-478e-ad77-99f68a2b7d0c", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "ls_x.bounds()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c2be060c-8f87-4b5c-8262-619768f6e6af", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "ls_x.get_selected_indices()" + ] + }, + { + "cell_type": "markdown", + "id": "d1bef432-d764-4841-bd6d-9b9e4c86ff62", + "metadata": {}, + "source": [ + "### However, for the y-axis line we have passed a 2D array where we've used a linspace, so there is not a 1-1 mapping from the data to the line geometry positions. Use `get_selected_indices()` to get the indices of the data bounded by the current selection. In addition the position of the Graphic is not `(0, 0)`. You must use `get_selected_indices()` whenever you want the indices of the selected data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c370d6d7-d92a-4680-8bf0-2f9d541028be", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "ls_y.bounds()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cdf351e1-63a2-4f5a-8199-8ac3f70909c1", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "ls_y.get_selected_indices()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6fd608ad-9732-4f50-9d43-8630603c86d0", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import fastplotlib as fpl\n", + "import numpy as np\n", + "\n", + "# data to plot\n", + "xs = np.linspace(0, 100, 1_000)\n", + "sine = np.sin(xs) * 20\n", + "cosine = np.cos(xs) * 20\n", + "\n", + "plot = fpl.GridPlot((5, 1))\n", + "\n", + "# sines and cosines\n", + "sines = [sine] * 2\n", + "cosines = [cosine] * 2\n", + "\n", + "# make line stack\n", + "line_stack = plot[0, 0].add_line_stack(sines + cosines, separation=50)\n", + "\n", + "# make selector\n", + "selector = line_stack.add_linear_selector()\n", + "\n", + "# populate subplots with preallocated graphics\n", + "for i, subplot in enumerate(plot):\n", + " if i == 0:\n", + " # skip the first one\n", + " continue\n", + " # make line graphics for displaying zoomed data\n", + " subplot.add_line(zoomed_init, name=\"zoomed\")\n", + "\n", + "\n", + "def update_zoomed_subplots(ev):\n", + " \"\"\"update the zoomed subplots\"\"\"\n", + " zoomed_data = selector.get_selected_data()\n", + " \n", + " for i in range(len(zoomed_data)):\n", + " data = interpolate(zoomed_data[i], axis=1)\n", + " plot[i + 1, 0][\"zoomed\"].data = data\n", + " plot[i + 1, 0].auto_scale()\n", + "\n", + "\n", + "selector.bounds.add_event_handler(update_zoomed_subplots)\n", + "plot.show()" + ] + }, + { + "cell_type": "markdown", + "id": "63acd2b6-958e-458d-bf01-903037644cfe", + "metadata": {}, + "source": [ + "# Large line stack with selector" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20e53223-6ccd-4145-bf67-32eb409d3b0a", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import fastplotlib as fpl\n", + "import numpy as np\n", + "\n", + "# data to plot\n", + "xs = np.linspace(0, 250, 10_000)\n", + "sine = np.sin(xs) * 20\n", + "cosine = np.cos(xs) * 20\n", + "\n", + "plot = fpl.GridPlot((1, 2))\n", + "\n", + "# sines and cosines\n", + "sines = [sine] * 1_00\n", + "cosines = [cosine] * 1_00\n", + "\n", + "# make line stack\n", + "line_stack = plot[0, 0].add_line_stack(sines + cosines, separation=50)\n", + "\n", + "# make selector\n", + "stack_selector = line_stack.add_linear_selector(padding=200)\n", + "\n", + "zoomed_line_stack = plot[0, 1].add_line_stack([zoomed_init] * 2_000, separation=50, name=\"zoomed\")\n", + " \n", + "def update_zoomed_stack(ev):\n", + " \"\"\"update the zoomed subplots\"\"\"\n", + " zoomed_data = stack_selector.get_selected_data()\n", + " \n", + " for i in range(len(zoomed_data)):\n", + " data = interpolate(zoomed_data[i], axis=1)\n", + " zoomed_line_stack.graphics[i].data = data\n", + " \n", + " plot[0, 1].auto_scale()\n", + "\n", + "\n", + "stack_selector.bounds.add_event_handler(update_zoomed_stack)\n", + "plot.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3fa61ffd-43d5-42d0-b3e1-5541f58185cd", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot[0, 0].auto_scale()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "80e276ba-23b3-43d0-9e0c-86acab79ac67", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 7f9b9b98404c46cacaf2b7d1c712cc6ba70d9e31 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 23:00:00 -0400 Subject: [PATCH 23/25] add selected_data and selected_indices to event pick info --- fastplotlib/graphics/selectors/_linear.py | 30 +++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 9d717ea0c..991673114 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -37,7 +37,21 @@ class LinearBoundsFeature(GraphicFeature): - """Feature for a linear bounding region""" + """ + Feature for a linear 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): super(LinearBoundsFeature, self).__init__(parent, data=bounds) @@ -94,11 +108,23 @@ def _set(self, value): self._feature_changed(key=None, new_data=value) def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): + 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 + "new_data": new_data, + "selected_indices": selected_ixs, + "selected_data": selected_data } event_data = FeatureEvent(type="bounds", pick_info=pick_info) From a679493ab699f993dd9182c91e7522bd5c6a557d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 23:11:50 -0400 Subject: [PATCH 24/25] rename to LinearRegionSelector --- fastplotlib/graphics/line.py | 15 ++++++++------- fastplotlib/graphics/line_collection.py | 15 ++++++++------- fastplotlib/graphics/selectors/_linear.py | 10 +++++----- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 4732d4339..aad1d337f 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -6,7 +6,7 @@ from ._base import Graphic, Interaction, PreviouslyModifiedData from .features import PointsDataFeature, ColorFeature, CmapFeature, ThicknessFeature -from .selectors import LinearSelector +from .selectors import LinearRegionSelector from ..utils import make_colors @@ -99,10 +99,10 @@ def __init__( if z_position is not None: self.world_object.position.z = z_position - def add_linear_selector(self, padding: float = 100.0, **kwargs) -> LinearSelector: + def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> LinearRegionSelector: """ - Add a ``LinearSelector``. Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them - from a plot area just like any other ``Graphic``. + 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 ---------- @@ -110,11 +110,11 @@ def add_linear_selector(self, padding: float = 100.0, **kwargs) -> LinearSelecto Extends the linear selector along the y-axis to make it easier to interact with. kwargs - passed to ``LinearSelector`` + passed to ``LinearRegionSelector`` Returns ------- - LinearSelector + LinearRegionSelector linear selection graphic """ @@ -122,7 +122,7 @@ def add_linear_selector(self, padding: float = 100.0, **kwargs) -> LinearSelecto bounds_init, limits, size, origin = self._get_linear_selector_init_args(padding, **kwargs) # create selector - selector = LinearSelector( + selector = LinearRegionSelector( bounds=bounds_init, limits=limits, size=size, @@ -140,6 +140,7 @@ def add_linear_selector(self, padding: float = 100.0, **kwargs) -> LinearSelecto return weakref.proxy(selector) 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(): diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index ec7881619..f89cc8b37 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -7,7 +7,7 @@ from ._base import Interaction, PreviouslyModifiedData, GraphicCollection from .line import LineGraphic -from .selectors import LinearSelector +from .selectors import LinearRegionSelector from ..utils import make_colors @@ -195,10 +195,11 @@ def __init__( self.add_graphic(lg, reset_index=False) - def add_linear_selector(self, padding: float = 100.0, **kwargs) -> LinearSelector: + def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> LinearRegionSelector: """ - Add a ``LinearSelector``. Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them - from a plot area just like any other ``Graphic``. + Add a ``LinearRegionSelector``. + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a plot area just like + any other ``Graphic``. Parameters ---------- @@ -206,11 +207,11 @@ def add_linear_selector(self, padding: float = 100.0, **kwargs) -> LinearSelecto Extends the linear selector along the y-axis to make it easier to interact with. kwargs - passed to ``LinearSelector`` + passed to ``LinearRegionSelector`` Returns ------- - LinearSelector + LinearRegionSelector linear selection graphic """ @@ -260,7 +261,7 @@ def add_linear_selector(self, padding: float = 100.0, **kwargs) -> LinearSelecto origin_x = (o[:, 0].min() + o[:, 0].max()) / 2 origin = (origin_x, limits[0]) - selector = LinearSelector( + selector = LinearRegionSelector( bounds=bounds, limits=limits, size=size, diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 991673114..8f68a754a 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -38,7 +38,7 @@ class LinearBoundsFeature(GraphicFeature): """ - Feature for a linear bounding region + Feature for a linearly bounding region Pick Info --------- @@ -132,7 +132,7 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): self._call_event_handlers(event_data) -class LinearSelector(Graphic, Interaction): +class LinearRegionSelector(Graphic, Interaction): feature_events = ( "bounds" ) @@ -151,8 +151,8 @@ def __init__( name: str = None ): """ - Create a LinearSelector graphic which can be moved only along the x-axis. Useful for sub-selecting - data on Line graphics. + 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 @@ -211,7 +211,7 @@ def __init__( # f"{limits[0]} != {origin[1]} != {bounds[0]}" # ) - super(LinearSelector, self).__init__(name=name) + super(LinearRegionSelector, self).__init__(name=name) self.parent = parent From f9cb8d1a2ec230b03ae5c6bece7e674f78067837 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 23:16:17 -0400 Subject: [PATCH 25/25] update example nb --- examples/linear_selector.ipynb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/linear_selector.ipynb b/examples/linear_selector.ipynb index d39709694..255598ba1 100644 --- a/examples/linear_selector.ipynb +++ b/examples/linear_selector.ipynb @@ -45,8 +45,8 @@ "sine_graphic_y.position.set_y(50)\n", "\n", "# add linear selectors\n", - "ls_x = sine_graphic_x.add_linear_selector() # default axis is \"x\"\n", - "ls_y = sine_graphic_y.add_linear_selector(axis=\"y\")\n", + "ls_x = sine_graphic_x.add_linear_region_selector() # default axis is \"x\"\n", + "ls_y = sine_graphic_y.add_linear_region_selector(axis=\"y\")\n", "\n", "# preallocate array for storing zoomed in data\n", "zoomed_init = np.column_stack([np.arange(zoomed_prealloc), np.random.rand(zoomed_prealloc)])\n", @@ -67,14 +67,14 @@ "\n", "def set_zoom_x(ev):\n", " \"\"\"sets zoomed x selector data\"\"\"\n", - " selected_data = ls_x.get_selected_data()\n", + " selected_data = ev.pick_info[\"selected_data\"]\n", " zoomed_x.data = interpolate(selected_data, axis=1) # use the y-values\n", " gp[1, 0].auto_scale()\n", "\n", "\n", "def set_zoom_y(ev):\n", " \"\"\"sets zoomed y selector data\"\"\"\n", - " selected_data = ls_y.get_selected_data()\n", + " selected_data = ev.pick_info[\"selected_data\"]\n", " zoomed_y.data = -interpolate(selected_data, axis=0) # use the x-values\n", " gp[1, 1].auto_scale()\n", "\n", @@ -177,7 +177,7 @@ "line_stack = plot[0, 0].add_line_stack(sines + cosines, separation=50)\n", "\n", "# make selector\n", - "selector = line_stack.add_linear_selector()\n", + "selector = line_stack.add_linear_region_selector()\n", "\n", "# populate subplots with preallocated graphics\n", "for i, subplot in enumerate(plot):\n", @@ -237,7 +237,7 @@ "line_stack = plot[0, 0].add_line_stack(sines + cosines, separation=50)\n", "\n", "# make selector\n", - "stack_selector = line_stack.add_linear_selector(padding=200)\n", + "stack_selector = line_stack.add_linear_region_selector(padding=200)\n", "\n", "zoomed_line_stack = plot[0, 1].add_line_stack([zoomed_init] * 2_000, separation=50, name=\"zoomed\")\n", " \n", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy