From e11ab3f6d5774913ac59afd3e47c12f4a9d3e5b6 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 04:22:53 -0400 Subject: [PATCH 01/17] better line slider, works --- fastplotlib/graphics/line_slider.py | 368 ++++++++++++++++++++++++---- 1 file changed, 322 insertions(+), 46 deletions(-) diff --git a/fastplotlib/graphics/line_slider.py b/fastplotlib/graphics/line_slider.py index f19db9cda..74baf20d9 100644 --- a/fastplotlib/graphics/line_slider.py +++ b/fastplotlib/graphics/line_slider.py @@ -1,22 +1,81 @@ from typing import * +import math import numpy as np import pygfx -from pygfx import TransformGizmo, Color -from ipywidgets import IntSlider +from pygfx.linalg import Vector3 -from ._base import Graphic +try: + import ipywidgets + HAS_IPYWIDGETS = True +except: + HAS_IPYWIDGETS = False + +from ._base import Graphic, GraphicFeature +from .features._base import FeatureEvent + + +class SliderValueFeature(GraphicFeature): + """ + A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system + + **pick info** + + +-----------------+----------------------------------------------------------------+ + | key | value | + +=================+================================================================+ + | "new_data" | the new slider position in world coordinates | + | "graphic_index" | the graphic data index that corresponds to the slider position | + | "world_object" | parent world object | + +-----------------+----------------------------------------------------------------+ + + """ + def __init__(self, parent, axis: str, value: float): + super(SliderValueFeature, self).__init__(parent, data=value) + + self.axis = axis + + def _set(self, value: float): + if self.axis == "x": + self._parent.position.x = value + else: + self._parent.position.y = value + + self._data = 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: + g_ix = self._parent.get_selected_index() + else: + g_ix = None + + pick_info = { + "index": None, + "collection-index": self._collection_index, + "world_object": self._parent.world_object, + "new_data": new_data, + "graphic_index": g_ix + } + + event_data = FeatureEvent(type="slider", pick_info=pick_info) + + self._call_event_handlers(event_data) class LineSlider(Graphic): def __init__( self, - orientation: str = "v", - x_pos: float = None, - y_pos: float = None, - bounds: Tuple[int, int] = None, - slider: IntSlider = None, + value: int, + limits: Tuple[int, int], + axis: str = "x", + parent: Graphic = None, + end_points: Tuple[int, int] = None, + ipywidget_slider: ipywidgets.IntSlider = None, thickness: float = 2.5, color: Any = "w", name: str = None, @@ -26,21 +85,17 @@ def __init__( Parameters ---------- - orientation: str, default "v" - one of "v" - vertical, or "h" - horizontal - **Note**: horizontal has not been implemented yet - - x_pos: float, optional - x-position of slider + axis: str, default "x" + "x" | "y", the axis which the slider can move along - y_pos: float, optional - y-position of slider + origin: int + the initial position of the slider, x or y value depending on "axis" argument - bounds: 2-element int tuple, optional + end_points: (int, int) set length of slider by bounding it between two x-pos or two y-pos - slider: IntSlider, optional - pygfx slider to handle event for slider + ipywidget_slider: IntSlider, optional + ipywidget slider to associate with this graphic thickness: float, default 2.5 thickness of the slider @@ -50,61 +105,282 @@ def __init__( name: str, optional name of line slider + + Features + -------- + value: SliderValueFeature + | value() returns the current slider position in world coordinates + | use value.add_event_handler() to add callback functions that are called + when the LineSlider value changes. See feaure class for event pick_info table + """ - if orientation == "v": - if x_pos is None: - raise ValueError("Must pass `x_pos` if orientation is 'v'") + self.limits = tuple(map(round, limits)) + value = round(value) + + if axis == "x": xs = np.zeros(2) - ys = np.array([bounds[0], bounds[1]]) + ys = np.array(end_points) zs = np.zeros(2) - data = np.ascontiguousarray(np.array([xs, ys, zs]).T).astype(np.float32) - - elif orientation == "h": - raise ValueError("'h' not yet supported") - if y_pos is None: - raise ValueError("Must pass `y_pos` if orientation is 'h'") + line_data = np.column_stack([xs, ys, zs]) + elif axis == "y": + xs = np.zeros(end_points) + ys = np.array(2) + zs = np.zeros(2) + line_data = np.column_stack([xs, ys, zs]) else: - raise ValueError("`orientation` must be one of 'v' or 'h'") + raise ValueError("`axis` must be one of 'v' or 'h'") + + line_data = line_data.astype(np.float32) + + self.axis = axis + + super(LineSlider, self).__init__(name=name) if thickness < 1.1: material = pygfx.LineThinMaterial else: material = pygfx.LineMaterial - colors_inner = np.repeat([Color(color)], 2, axis=0).astype(np.float32) - colors_outer = np.repeat([Color([1., 1., 1., 0.25])], 2, axis=0).astype(np.float32) + colors_inner = np.repeat([pygfx.Color(color)], 2, axis=0).astype(np.float32) + self.colors_outer = np.repeat([pygfx.Color([0.3, 0.3, 0.3, 1.0])], 2, axis=0).astype(np.float32) line_inner = pygfx.Line( # self.data.feature_data because data is a Buffer - geometry=pygfx.Geometry(positions=data, colors=colors_inner), + geometry=pygfx.Geometry(positions=line_data, colors=colors_inner), material=material(thickness=thickness, vertex_colors=True) ) - line_outer = pygfx.Line( - geometry=pygfx.Geometry(positions=data, colors=colors_outer), - material=material(thickness=thickness + 4, vertex_colors=True) + self.line_outer = pygfx.Line( + geometry=pygfx.Geometry(positions=line_data, colors=self.colors_outer.copy()), + material=material(thickness=thickness + 6, vertex_colors=True) ) + line_inner.position.z = self.line_outer.position.z + 1 + world_object = pygfx.Group() - world_object.add(line_outer) + world_object.add(self.line_outer) world_object.add(line_inner) self._set_world_object(world_object) - self.position.x = x_pos + # set x or y position + pos = getattr(self.position, axis) + pos = value + + self.value = SliderValueFeature(self, axis=axis, value=value) + + self.ipywidget_slider = ipywidget_slider + + if self.ipywidget_slider is not None: + self._setup_ipywidget_slider(ipywidget_slider) + + self._move_info: dict = None + + self.parent = parent + + self._block_ipywidget_call = False + + def _setup_ipywidget_slider(self, widget): + # setup ipywidget slider with callbacks to this LineSlider + widget.value = int(self.value()) + widget.observe(self._ipywidget_callback, "value") + self.value.add_event_handler(self._update_ipywidget) + + def _update_ipywidget(self, ev): + # update the ipywidget slider value when LineSlider value changes + self._block_ipywidget_call = True + self.ipywidget_slider.value = int(ev.pick_info["new_data"]) + self._block_ipywidget_call = False + + def _ipywidget_callback(self, change): + # update the LineSlider if the ipywidget value changes + if self._block_ipywidget_call: + return + + self.value = change["new"] + + def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): + """ + Makes and returns an ipywidget slider that is associated to this LineSlider + + Parameters + ---------- + kind: str + "IntSlider" or "FloatSlider" + + kwargs + passed to the ipywidget slider constructor + + Returns + ------- + ipywidgets.Intslider or ipywidgets.FloatSlider + + """ + if self.ipywidget_slider is not None: + raise AttributeError("Already has ipywidget slider") + + if not HAS_IPYWIDGETS: + raise ImportError("Must installed `ipywidgets` to use `make_ipywidget_slider()`") + + cls = getattr(ipywidgets, kind) + + slider = cls( + min=self.limits[0], + max=self.limits[1], + value=int(self.value()), + step=1, + **kwargs + ) + self.ipywidget_slider = slider + self._setup_ipywidget_slider(slider) + + return slider + + def get_selected_index(self, graphic: Graphic = None) -> int: + """ + Data index the slider is currently at w.r.t. the Graphic data. + + Parameters + ---------- + graphic: Graphic, optional + Graphic to get the selected data index from. Default is the parent graphic associated to the slider. + + Returns + ------- + int + data index the slider is currently at + """ + + graphic = self._get_source(graphic) + + # the array to search for the closest value along that axis + if self.axis == "x": + to_search = graphic.data()[:, 0] + offset = getattr(graphic.position, self.axis) + else: + to_search = graphic.data()[:, 1] + offset = getattr(graphic.position, self.axis) + + find_value = self.value() - offset + + # get closest data index to the world space position of the slider + idx = np.searchsorted(to_search, find_value, side="left") + + if idx > 0 and (idx == len(to_search) or math.fabs(find_value - to_search[idx - 1]) < math.fabs(find_value - to_search[idx])): + return idx - 1 + else: + return idx + + def _get_source(self, graphic): + if self.parent is None and graphic is None: + raise AttributeError( + "No Graphic to apply selector. " + "You must either set a ``parent`` Graphic on the selector, or pass a graphic." + ) + + # use passed graphic if provided, else use parent + if graphic is not None: + source = graphic + else: + source = self.parent + + return source + + def _add_plot_area_hook(self, plot_area): + self._plot_area = plot_area + + # move events + self.world_object.add_event_handler(self._move_start, "pointer_down") + self._plot_area.renderer.add_event_handler(self._move, "pointer_move") + self._plot_area.renderer.add_event_handler(self._move_end, "pointer_up") + + # move directly to location of center mouse button click + self._plot_area.renderer.add_event_handler(self._move_to_pointer, "click") + + # mouse hover color events + self.world_object.add_event_handler(self._pointer_enter, "pointer_enter") + self.world_object.add_event_handler(self._pointer_leave, "pointer_leave") + + def _move_to_pointer(self, ev): + # middle mouse button clicks + if ev.button != 3: + return + + click_pos = (ev.x, ev.y) + world_pos = self._plot_area.map_screen_to_world(click_pos) + + # outside this viewport + if world_pos is None: + return + + if self.axis == "x": + self.value = world_pos.x + else: + self.value = world_pos.y + + def _move_start(self, ev): + self._move_info = {"last_pos": (ev.x, ev.y)} + + def _move(self, ev): + if self._move_info is None: + return + + self._plot_area.controller.enabled = False + + last = self._move_info["last_pos"] + + # new - last + # pointer move events are in viewport or canvas space + delta = Vector3(ev.x - last[0], ev.y - last[1]) + + 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 + # also since these are just deltas we don't have to calculate positions relative to the viewport + delta_ndc = delta.multiply( + Vector3( + 2 / viewport_size[0], + -2 / viewport_size[1], + 0 + ) + ) + + camera = self._plot_area.camera + + # current world position + vec = self.position.clone() + + # compute and add delta in projected NDC space and then unproject back to world space + vec.project(camera).add(delta_ndc).unproject(camera) + + new_value = getattr(vec, self.axis) + + if new_value < self.limits[0] or new_value > self.limits[1]: + return + + self.value = new_value + self._plot_area.controller.enabled = True + + def _move_end(self, ev): + self._move_info = None + self._plot_area.controller.enabled = True - self.slider = slider - self.slider.observe(self.set_position, "value") + def _pointer_enter(self, ev): + self.line_outer.geometry.colors.data[:] = np.repeat([pygfx.Color("magenta")], 2, axis=0) + self.line_outer.geometry.colors.update_range() - super().__init__(name=name) + def _pointer_leave(self, ev): + if self._move_info is not None: + return - def set_position(self, change): - self.position.x = change["new"] + self._reset_color() - # def _add_plot_area_hook(self, viewport, camera): - # self.gizmo = TransformGizmo(self.world_object) - # self.gizmo.add_default_event_handlers(viewport, camera) + def _reset_color(self): + self.line_outer.geometry.colors.data[:] = self.colors_outer + self.line_outer.geometry.colors.update_range() From 718eee79d9dfea9abefe485cbe8470ab466ba069 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 04:50:11 -0400 Subject: [PATCH 02/17] lineslider events works --- fastplotlib/graphics/line_slider.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/fastplotlib/graphics/line_slider.py b/fastplotlib/graphics/line_slider.py index 74baf20d9..f3870d266 100644 --- a/fastplotlib/graphics/line_slider.py +++ b/fastplotlib/graphics/line_slider.py @@ -22,13 +22,14 @@ class SliderValueFeature(GraphicFeature): **pick info** - +-----------------+----------------------------------------------------------------+ - | key | value | - +=================+================================================================+ - | "new_data" | the new slider position in world coordinates | - | "graphic_index" | the graphic data index that corresponds to the slider position | - | "world_object" | parent world object | - +-----------------+----------------------------------------------------------------+ + +------------------+----------------------------------------------------------------+ + | key | value | + +==================+================================================================+ + | "new_data" | the new slider position in world coordinates | + | "selected_index" | the graphic data index that corresponds to the slider position | + | "world_object" | parent world object | + | "graphic" | LineSlider instance | + +------------------+----------------------------------------------------------------+ """ def __init__(self, parent, axis: str, value: float): @@ -59,7 +60,8 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): "collection-index": self._collection_index, "world_object": self._parent.world_object, "new_data": new_data, - "graphic_index": g_ix + "selected_index": g_ix, + "graphic": self._parent } event_data = FeatureEvent(type="slider", pick_info=pick_info) @@ -189,6 +191,7 @@ def _setup_ipywidget_slider(self, widget): widget.value = int(self.value()) widget.observe(self._ipywidget_callback, "value") self.value.add_event_handler(self._update_ipywidget) + self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize") def _update_ipywidget(self, ev): # update the ipywidget slider value when LineSlider value changes @@ -203,6 +206,11 @@ def _ipywidget_callback(self, change): self.value = change["new"] + def _set_slider_layout(self, *args): + w, h = self._plot_area.renderer.logical_size + + self.ipywidget_slider.layout = ipywidgets.Layout(width=f"{w}px") + def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): """ Makes and returns an ipywidget slider that is associated to this LineSlider @@ -271,9 +279,9 @@ def get_selected_index(self, graphic: Graphic = None) -> int: idx = np.searchsorted(to_search, find_value, side="left") if idx > 0 and (idx == len(to_search) or math.fabs(find_value - to_search[idx - 1]) < math.fabs(find_value - to_search[idx])): - return idx - 1 + return int(idx - 1) else: - return idx + return int(idx) def _get_source(self, graphic): if self.parent is None and graphic is None: From bed5a955eb27708dd86ff46b78c0e550ed0c3838 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 06:13:33 -0400 Subject: [PATCH 03/17] docstrings --- fastplotlib/graphics/line_slider.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/fastplotlib/graphics/line_slider.py b/fastplotlib/graphics/line_slider.py index f3870d266..2db4b5deb 100644 --- a/fastplotlib/graphics/line_slider.py +++ b/fastplotlib/graphics/line_slider.py @@ -17,19 +17,19 @@ class SliderValueFeature(GraphicFeature): + # A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system """ - A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system + Manages the slider value and callbacks **pick info** - +------------------+----------------------------------------------------------------+ - | key | value | - +==================+================================================================+ - | "new_data" | the new slider position in world coordinates | - | "selected_index" | the graphic data index that corresponds to the slider position | - | "world_object" | parent world object | - | "graphic" | LineSlider instance | - +------------------+----------------------------------------------------------------+ + ================== ================================================================ + key value + ================== ================================================================ + "new_data" the new slider position in world coordinates + "selected_index" the graphic data index that corresponds to the slider position + "world_object" parent world object + ================== ================================================================ """ def __init__(self, parent, axis: str, value: float): @@ -110,10 +110,11 @@ def __init__( Features -------- - value: SliderValueFeature - | value() returns the current slider position in world coordinates - | use value.add_event_handler() to add callback functions that are called - when the LineSlider value changes. See feaure class for event pick_info table + + value: :class:`SliderValueFeature` + ``value()`` returns the current slider position in world coordinates + use ``value.add_event_handler()`` to add callback functions that are + called when the LineSlider value changes. See feaure class for event pick_info table """ From 4b0f7a9f22ecddb630a167f67db4240d2fa4f4fb Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Mon, 17 Apr 2023 23:20:08 -0400 Subject: [PATCH 04/17] LinearRegionSelector (#164) LinearRegionSelector, can select on x or y axis entire selection responds to mouse events edges allow resizing with mouse events, edges change color and thickness with interaction `LinearSelector.get_selected_data()` returns a view of the data, or a list of views if from a collection such as LineStack `LinearBoundsFeature` manages events. `LineGraphic.add_linear_region_selector()`, allows adding multiple selectors onto the same graphic `LineCollection.add_linear_region_selector()` --- examples/linear_selector.ipynb | 301 +++++++++++ fastplotlib/graphics/features/_base.py | 2 +- fastplotlib/graphics/line.py | 87 +++ fastplotlib/graphics/line_collection.py | 92 +++- fastplotlib/graphics/selectors/__init__.py | 1 + fastplotlib/graphics/selectors/_linear.py | 593 +++++++++++++++++++++ fastplotlib/layouts/_base.py | 4 +- fastplotlib/plot.py | 5 +- 8 files changed, 1078 insertions(+), 7 deletions(-) create mode 100644 examples/linear_selector.ipynb create mode 100644 fastplotlib/graphics/selectors/__init__.py create mode 100644 fastplotlib/graphics/selectors/_linear.py diff --git a/examples/linear_selector.ipynb b/examples/linear_selector.ipynb new file mode 100644 index 000000000..255598ba1 --- /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_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", + "\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 = 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 = 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", + "\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_region_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_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", + "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 +} 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/line.py b/fastplotlib/graphics/line.py index 0b1e579bc..aad1d337f 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -1,9 +1,12 @@ from typing import * +import weakref + import numpy as np import pygfx from ._base import Graphic, Interaction, PreviouslyModifiedData from .features import PointsDataFeature, ColorFeature, CmapFeature, ThicknessFeature +from .selectors import LinearRegionSelector from ..utils import make_colors @@ -96,6 +99,90 @@ def __init__( if z_position is not None: self.world_object.position.z = z_position + def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> LinearRegionSelector: + """ + Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + padding: float, default 100.0 + Extends the linear selector along the y-axis to make it easier to interact with. + + kwargs + passed to ``LinearRegionSelector`` + + Returns + ------- + LinearRegionSelector + linear selection graphic + + """ + + bounds_init, limits, size, origin = self._get_linear_selector_init_args(padding, **kwargs) + + # create selector + selector = LinearRegionSelector( + 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): + # computes initial bounds, limits, size and origin of linear selectors + data = self.data() + + if "axis" in kwargs.keys(): + axis = kwargs["axis"] + else: + axis = "x" + + if axis == "x": + offset = self.position.x + # x limits + 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 + + # 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] + 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 + + # need x offset too for this + origin = (position_x + self.position.x, limits[0] - offset) + + # initial bounds are 20% of the limits range + bounds_init = (limits[0], int(np.ptp(limits) * 0.2) + offset) + + return bounds_init, limits, size, origin + + 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/line_collection.py b/fastplotlib/graphics/line_collection.py index 3bff6f7c5..f89cc8b37 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 LinearRegionSelector from ..utils import make_colors -from copy import deepcopy class LineCollection(GraphicCollection, Interaction): @@ -192,6 +195,89 @@ def __init__( self.add_graphic(lg, reset_index=False) + def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> LinearRegionSelector: + """ + 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 + ---------- + padding: float, default 100.0 + Extends the linear selector along the y-axis to make it easier to interact with. + + kwargs + passed to ``LinearRegionSelector`` + + Returns + ------- + LinearRegionSelector + linear selection graphic + + """ + + bounds_init = 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) + size += self.separation * len(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 = LinearRegionSelector( + 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() @@ -346,3 +432,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 diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py new file mode 100644 index 000000000..552b938ef --- /dev/null +++ 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 new file mode 100644 index 000000000..8f68a754a --- /dev/null +++ b/fastplotlib/graphics/selectors/_linear.py @@ -0,0 +1,593 @@ +from typing import * +import numpy as np +from functools import partial + +import pygfx +from pygfx.linalg import Vector3 + +from .._base import Graphic, Interaction, GraphicCollection +from ..features._base import GraphicFeature, FeatureEvent + + +# positions for indexing the BoxGeometry to set the "width" and "size" of the box +# hacky, but I don't think we can morph meshes in pygfx yet: https://github.com/pygfx/pygfx/issues/346 +x_right = np.array([ + True, True, True, True, False, False, False, False, False, + True, False, True, True, False, True, False, False, True, + False, True, True, False, True, False +]) + +x_left = np.array([ + False, False, False, False, True, True, True, True, True, + False, True, False, False, True, False, True, True, False, + True, False, False, True, False, True +]) + +y_top = np.array([ + False, True, False, True, False, True, False, True, True, + True, True, True, False, False, False, False, False, False, + True, True, False, False, True, True +]) + +y_bottom = np.array([ + True, False, True, False, True, False, True, False, False, + False, False, False, True, True, True, True, True, True, + False, False, True, True, False, False +]) + + +class LinearBoundsFeature(GraphicFeature): + """ + Feature for a linearly bounding region + + Pick Info + --------- + + +--------------------+-------------------------------+--------------------------------------------------------------------------------------+ + | key | type | description | + +====================+===============================+======================================================================================+ + | "selected_indices" | ``numpy.ndarray`` or ``None`` | selected graphic data indices | + | "selected_data" | ``numpy.ndarray`` or ``None`` | selected graphic data | + | "new_data" | ``(float, float)`` | current bounds in world coordinates, NOT necessarily the same as "selected_indices". | + +--------------------+-------------------------------+--------------------------------------------------------------------------------------+ + + """ + def __init__(self, parent, bounds: Tuple[int, int], axis: str): + 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): + raise TypeError( + "Bounds must be a tuple in the form of `(min_bound, max_bound)`, " + "where `min_bound` and `max_bound` are numeric values." + ) + + 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 top position of the fill mesh + self._parent.fill.geometry.positions.data[y_top, 1] = value[1] + + # change y position of the bottom edge line + self._parent.edges[0].geometry.positions.data[:, 1] = value[0] + + # 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]) + + # 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): + if len(self._event_handlers) < 1: + return + + if self._parent.parent is not None: + selected_ixs = self._parent.get_selected_indices() + selected_data = self._parent.get_selected_data() + else: + selected_ixs = None + selected_data = None + + pick_info = { + "index": None, + "collection-index": self._collection_index, + "world_object": self._parent.world_object, + "new_data": new_data, + "selected_indices": selected_ixs, + "selected_data": selected_data + } + + event_data = FeatureEvent(type="bounds", pick_info=pick_info) + + self._call_event_handlers(event_data) + + +class LinearRegionSelector(Graphic, Interaction): + feature_events = ( + "bounds" + ) + + def __init__( + self, + bounds: Tuple[int, int], + limits: Tuple[int, int], + size: int, + origin: Tuple[int, int], + axis: str = "x", + parent: Graphic = None, + resizable: bool = True, + fill_color=(0, 0, 0.35), + edge_color=(0.8, 0.8, 0), + name: str = None + ): + """ + Create a LinearRegionSelector graphic which can be moved only along either the x-axis or y-axis. + Allows sub-selecting data from a ``Graphic`` or from multiple Graphics. + + bounds[0], limits[0], and position[0] must be identical + + Parameters + ---------- + bounds: (int, int) + the initial bounds of the linear selector + + limits: (int, int) + (min limit, max limit) for the selector + + size: int + height or width of the selector + + origin: (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 + + fill_color: str, array, or tuple + fill color for the selector, passed to pygfx.Color + + edge_color: str, array, or tuple + edge color for the selector, passed to pygfx.Color + + name: str + name for this selector graphic + """ + + # lots of very close to zero values etc. so round them + bounds = tuple(map(round, bounds)) + limits = tuple(map(round, limits)) + origin = tuple(map(round, origin)) + + # 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(LinearRegionSelector, 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 + group = pygfx.Group() + self._set_world_object(group) + + 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(*origin, -2) + + self.world_object.add(self.fill) + + # will be used to store the mouse pointer x y movements + # so deltas can be calculated for interacting with the selection + self._move_info = None + + # mouse events can come from either the fill mesh world object, or one of the lines on the edge of the selector + self._event_source: str = None + + self.limits = limits + self._resizable = resizable + + self._edge_color = np.repeat([pygfx.Color(edge_color)], 2, axis=0) + + if axis == "x": + # position data for the left edge line + left_line_data = np.array( + [[origin[0], (-size / 2) + origin[1], 0.5], + [origin[0], (size / 2) + origin[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) + ) + + # position data for the right edge line + right_line_data = np.array( + [[bounds[1], (-size / 2) + origin[1], 0.5], + [bounds[1], (size / 2) + origin[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) + ) + + 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) + origin[0], origin[1], 0.5], + [(size / 2) + origin[0], origin[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) + origin[0], bounds[1], 0.5], + [(size / 2) + origin[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] = (bottom_line, top_line) + + # add the edge lines + for edge in self.edges: + edge.position.set_z(-1) + self.world_object.add(edge) + + # highlight the edges when mouse is hovered + for edge_line in self.edges: + edge_line.add_event_handler( + partial(self._pointer_enter_edge, edge_line), + "pointer_enter" + ) + edge_line.add_event_handler(self._pointer_leave_edge, "pointer_leave") + + # set the initial bounds of the selector + self._bounds = LinearBoundsFeature(self, bounds, axis=axis) + self._bounds: LinearBoundsFeature = bounds + + @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], None]: + """ + 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. + + **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 + ------- + 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) + + 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 + + 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]]: + """ + 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) + 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)) + + if self.bounds.axis == "x": + dim = 0 + else: + dim = 1 + # now we need to map from graphic space to data space + # we can have more than 1 datapoint between two integer locations in the world space + if isinstance(source, GraphicCollection): + ixs = list() + for g in source.graphics: + # map for each graphic in the collection + g_ixs = np.where( + (g.data()[:, dim] >= offset_bounds[0]) & (g.data()[:, dim] <= offset_bounds[1]) + )[0] + ixs.append(g_ixs) + else: + # map this only this graphic + ixs = np.where( + (source.data()[:, dim] >= offset_bounds[0]) & (source.data()[:, dim] <= offset_bounds[1]) + )[0] + + return ixs + + def _get_source(self, graphic): + if self.parent is None and graphic is None: + raise AttributeError( + "No Graphic to apply selector. " + "You must either set a ``parent`` Graphic on the selector, or pass a graphic." + ) + + # use passed graphic if provided, else use parent + if graphic is not None: + source = graphic + else: + source = self.parent + + return source + + def _add_plot_area_hook(self, plot_area): + # called when this selector is added to a plot area + self._plot_area = plot_area + + # need partials so that the source of the event is passed to the `_move_start` handler + self._move_start_fill = partial(self._move_start, "fill") + self._move_start_edge_0 = partial(self._move_start, "edge-0") + self._move_start_edge_1 = partial(self._move_start, "edge-1") + + self.fill.add_event_handler(self._move_start_fill, "pointer_down") + + if self._resizable: + self.edges[0].add_event_handler(self._move_start_edge_0, "pointer_down") + self.edges[1].add_event_handler(self._move_start_edge_1, "pointer_down") + + self._plot_area.renderer.add_event_handler(self._move, "pointer_move") + self._plot_area.renderer.add_event_handler(self._move_end, "pointer_up") + + def _move_start(self, event_source: str, ev): + """ + Parameters + ---------- + event_source: str + "fill" | "edge-left" | "edge-right" + + """ + # self._plot_area.controller.enabled = False + # last pointer position + self._move_info = {"last_pos": (ev.x, ev.y)} + self._event_source = event_source + + def _move(self, ev): + if self._move_info is None: + return + + # disable the controller, otherwise the panzoom or other controllers will move the camera and will not + # allow the selector to process the mouse events + self._plot_area.controller.enabled = False + + last = self._move_info["last_pos"] + + # new - last + # pointer move events are in viewport or canvas space + delta = Vector3(ev.x - last[0], ev.y - last[1]) + + 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 + # also since these are just deltas we don't have to calculate positions relative to the viewport + delta_ndc = delta.multiply( + Vector3( + 2 / viewport_size[0], + -2 / viewport_size[1], + 0 + ) + ) + + camera = self._plot_area.camera + + # edge-0 bound current world position + if self.bounds.axis == "x": + # left bound position + vec0 = Vector3(self.bounds()[0]) + else: + # bottom bound position + vec0 = Vector3(0, self.bounds()[0]) + # compute and add delta in projected NDC space and then unproject back to world space + vec0.project(camera).add(delta_ndc).unproject(camera) + + # 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 + vec1.project(camera).add(delta_ndc).unproject(camera) + + 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-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 + bound0 = getattr(vec0, self.bounds.axis) + bound1 = getattr(vec1, self.bounds.axis) + + # if the limits are met do nothing + if bound0 < self.limits[0] or bound1 > self.limits[1]: + return + + # make sure `selector width >= 2`, left edge must not move past right edge! + # or bottom edge must not move past top edge! + # has to be at least 2 otherwise can't join datapoints for lines + if not (bound1 - bound0) >= 2: + return + + # set the new bounds + self.bounds = (bound0, bound1) + + # re-enable the controller + self._plot_area.controller.enabled = True + + def _move_end(self, ev): + self._move_info = None + # sometimes weird stuff happens so we want to make sure the controller is reset + self._plot_area.controller.enabled = True + + self._reset_edge_color() + + def _pointer_enter_edge(self, edge: pygfx.Line, ev): + edge.material.thickness = 6 + edge.geometry.colors.data[:] = np.repeat([pygfx.Color("magenta")], 2, axis=0) + edge.geometry.colors.update_range() + + def _pointer_leave_edge(self, ev): + if self._move_info is not None and self._event_source.startswith("edge"): + return + + self._reset_edge_color() + + def _reset_edge_color(self): + for edge in self.edges: + edge.material.thickness = 3 + edge.geometry.colors.data[:] = self._edge_color + edge.geometry.colors.update_range() + + def _set_feature(self, feature: str, new_data: Any, indices: Any): + pass + + def _reset_feature(self, feature: str): + pass + + def __del__(self): + self.fill.remove_event_handler(self._move_start_fill, "pointer_down") + + if self._resizable: + self.edges[0].remove_event_handler(self._move_start_edge_0, "pointer_down") + self.edges[1].remove_event_handler(self._move_start_edge_1, "pointer_down") + + self._plot_area.renderer.remove_event_handler(self._move, "pointer_move") + self._plot_area.renderer.remove_event_handler(self._move_end, "pointer_up") diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index ea24d87b1..69f77ba86 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -227,8 +227,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 4680b44eab9dcced037762bdc636f1a95602a552 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 04:22:53 -0400 Subject: [PATCH 05/17] better line slider, works --- fastplotlib/graphics/line_slider.py | 368 ++++++++++++++++++++++++---- 1 file changed, 322 insertions(+), 46 deletions(-) diff --git a/fastplotlib/graphics/line_slider.py b/fastplotlib/graphics/line_slider.py index f19db9cda..74baf20d9 100644 --- a/fastplotlib/graphics/line_slider.py +++ b/fastplotlib/graphics/line_slider.py @@ -1,22 +1,81 @@ from typing import * +import math import numpy as np import pygfx -from pygfx import TransformGizmo, Color -from ipywidgets import IntSlider +from pygfx.linalg import Vector3 -from ._base import Graphic +try: + import ipywidgets + HAS_IPYWIDGETS = True +except: + HAS_IPYWIDGETS = False + +from ._base import Graphic, GraphicFeature +from .features._base import FeatureEvent + + +class SliderValueFeature(GraphicFeature): + """ + A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system + + **pick info** + + +-----------------+----------------------------------------------------------------+ + | key | value | + +=================+================================================================+ + | "new_data" | the new slider position in world coordinates | + | "graphic_index" | the graphic data index that corresponds to the slider position | + | "world_object" | parent world object | + +-----------------+----------------------------------------------------------------+ + + """ + def __init__(self, parent, axis: str, value: float): + super(SliderValueFeature, self).__init__(parent, data=value) + + self.axis = axis + + def _set(self, value: float): + if self.axis == "x": + self._parent.position.x = value + else: + self._parent.position.y = value + + self._data = 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: + g_ix = self._parent.get_selected_index() + else: + g_ix = None + + pick_info = { + "index": None, + "collection-index": self._collection_index, + "world_object": self._parent.world_object, + "new_data": new_data, + "graphic_index": g_ix + } + + event_data = FeatureEvent(type="slider", pick_info=pick_info) + + self._call_event_handlers(event_data) class LineSlider(Graphic): def __init__( self, - orientation: str = "v", - x_pos: float = None, - y_pos: float = None, - bounds: Tuple[int, int] = None, - slider: IntSlider = None, + value: int, + limits: Tuple[int, int], + axis: str = "x", + parent: Graphic = None, + end_points: Tuple[int, int] = None, + ipywidget_slider: ipywidgets.IntSlider = None, thickness: float = 2.5, color: Any = "w", name: str = None, @@ -26,21 +85,17 @@ def __init__( Parameters ---------- - orientation: str, default "v" - one of "v" - vertical, or "h" - horizontal - **Note**: horizontal has not been implemented yet - - x_pos: float, optional - x-position of slider + axis: str, default "x" + "x" | "y", the axis which the slider can move along - y_pos: float, optional - y-position of slider + origin: int + the initial position of the slider, x or y value depending on "axis" argument - bounds: 2-element int tuple, optional + end_points: (int, int) set length of slider by bounding it between two x-pos or two y-pos - slider: IntSlider, optional - pygfx slider to handle event for slider + ipywidget_slider: IntSlider, optional + ipywidget slider to associate with this graphic thickness: float, default 2.5 thickness of the slider @@ -50,61 +105,282 @@ def __init__( name: str, optional name of line slider + + Features + -------- + value: SliderValueFeature + | value() returns the current slider position in world coordinates + | use value.add_event_handler() to add callback functions that are called + when the LineSlider value changes. See feaure class for event pick_info table + """ - if orientation == "v": - if x_pos is None: - raise ValueError("Must pass `x_pos` if orientation is 'v'") + self.limits = tuple(map(round, limits)) + value = round(value) + + if axis == "x": xs = np.zeros(2) - ys = np.array([bounds[0], bounds[1]]) + ys = np.array(end_points) zs = np.zeros(2) - data = np.ascontiguousarray(np.array([xs, ys, zs]).T).astype(np.float32) - - elif orientation == "h": - raise ValueError("'h' not yet supported") - if y_pos is None: - raise ValueError("Must pass `y_pos` if orientation is 'h'") + line_data = np.column_stack([xs, ys, zs]) + elif axis == "y": + xs = np.zeros(end_points) + ys = np.array(2) + zs = np.zeros(2) + line_data = np.column_stack([xs, ys, zs]) else: - raise ValueError("`orientation` must be one of 'v' or 'h'") + raise ValueError("`axis` must be one of 'v' or 'h'") + + line_data = line_data.astype(np.float32) + + self.axis = axis + + super(LineSlider, self).__init__(name=name) if thickness < 1.1: material = pygfx.LineThinMaterial else: material = pygfx.LineMaterial - colors_inner = np.repeat([Color(color)], 2, axis=0).astype(np.float32) - colors_outer = np.repeat([Color([1., 1., 1., 0.25])], 2, axis=0).astype(np.float32) + colors_inner = np.repeat([pygfx.Color(color)], 2, axis=0).astype(np.float32) + self.colors_outer = np.repeat([pygfx.Color([0.3, 0.3, 0.3, 1.0])], 2, axis=0).astype(np.float32) line_inner = pygfx.Line( # self.data.feature_data because data is a Buffer - geometry=pygfx.Geometry(positions=data, colors=colors_inner), + geometry=pygfx.Geometry(positions=line_data, colors=colors_inner), material=material(thickness=thickness, vertex_colors=True) ) - line_outer = pygfx.Line( - geometry=pygfx.Geometry(positions=data, colors=colors_outer), - material=material(thickness=thickness + 4, vertex_colors=True) + self.line_outer = pygfx.Line( + geometry=pygfx.Geometry(positions=line_data, colors=self.colors_outer.copy()), + material=material(thickness=thickness + 6, vertex_colors=True) ) + line_inner.position.z = self.line_outer.position.z + 1 + world_object = pygfx.Group() - world_object.add(line_outer) + world_object.add(self.line_outer) world_object.add(line_inner) self._set_world_object(world_object) - self.position.x = x_pos + # set x or y position + pos = getattr(self.position, axis) + pos = value + + self.value = SliderValueFeature(self, axis=axis, value=value) + + self.ipywidget_slider = ipywidget_slider + + if self.ipywidget_slider is not None: + self._setup_ipywidget_slider(ipywidget_slider) + + self._move_info: dict = None + + self.parent = parent + + self._block_ipywidget_call = False + + def _setup_ipywidget_slider(self, widget): + # setup ipywidget slider with callbacks to this LineSlider + widget.value = int(self.value()) + widget.observe(self._ipywidget_callback, "value") + self.value.add_event_handler(self._update_ipywidget) + + def _update_ipywidget(self, ev): + # update the ipywidget slider value when LineSlider value changes + self._block_ipywidget_call = True + self.ipywidget_slider.value = int(ev.pick_info["new_data"]) + self._block_ipywidget_call = False + + def _ipywidget_callback(self, change): + # update the LineSlider if the ipywidget value changes + if self._block_ipywidget_call: + return + + self.value = change["new"] + + def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): + """ + Makes and returns an ipywidget slider that is associated to this LineSlider + + Parameters + ---------- + kind: str + "IntSlider" or "FloatSlider" + + kwargs + passed to the ipywidget slider constructor + + Returns + ------- + ipywidgets.Intslider or ipywidgets.FloatSlider + + """ + if self.ipywidget_slider is not None: + raise AttributeError("Already has ipywidget slider") + + if not HAS_IPYWIDGETS: + raise ImportError("Must installed `ipywidgets` to use `make_ipywidget_slider()`") + + cls = getattr(ipywidgets, kind) + + slider = cls( + min=self.limits[0], + max=self.limits[1], + value=int(self.value()), + step=1, + **kwargs + ) + self.ipywidget_slider = slider + self._setup_ipywidget_slider(slider) + + return slider + + def get_selected_index(self, graphic: Graphic = None) -> int: + """ + Data index the slider is currently at w.r.t. the Graphic data. + + Parameters + ---------- + graphic: Graphic, optional + Graphic to get the selected data index from. Default is the parent graphic associated to the slider. + + Returns + ------- + int + data index the slider is currently at + """ + + graphic = self._get_source(graphic) + + # the array to search for the closest value along that axis + if self.axis == "x": + to_search = graphic.data()[:, 0] + offset = getattr(graphic.position, self.axis) + else: + to_search = graphic.data()[:, 1] + offset = getattr(graphic.position, self.axis) + + find_value = self.value() - offset + + # get closest data index to the world space position of the slider + idx = np.searchsorted(to_search, find_value, side="left") + + if idx > 0 and (idx == len(to_search) or math.fabs(find_value - to_search[idx - 1]) < math.fabs(find_value - to_search[idx])): + return idx - 1 + else: + return idx + + def _get_source(self, graphic): + if self.parent is None and graphic is None: + raise AttributeError( + "No Graphic to apply selector. " + "You must either set a ``parent`` Graphic on the selector, or pass a graphic." + ) + + # use passed graphic if provided, else use parent + if graphic is not None: + source = graphic + else: + source = self.parent + + return source + + def _add_plot_area_hook(self, plot_area): + self._plot_area = plot_area + + # move events + self.world_object.add_event_handler(self._move_start, "pointer_down") + self._plot_area.renderer.add_event_handler(self._move, "pointer_move") + self._plot_area.renderer.add_event_handler(self._move_end, "pointer_up") + + # move directly to location of center mouse button click + self._plot_area.renderer.add_event_handler(self._move_to_pointer, "click") + + # mouse hover color events + self.world_object.add_event_handler(self._pointer_enter, "pointer_enter") + self.world_object.add_event_handler(self._pointer_leave, "pointer_leave") + + def _move_to_pointer(self, ev): + # middle mouse button clicks + if ev.button != 3: + return + + click_pos = (ev.x, ev.y) + world_pos = self._plot_area.map_screen_to_world(click_pos) + + # outside this viewport + if world_pos is None: + return + + if self.axis == "x": + self.value = world_pos.x + else: + self.value = world_pos.y + + def _move_start(self, ev): + self._move_info = {"last_pos": (ev.x, ev.y)} + + def _move(self, ev): + if self._move_info is None: + return + + self._plot_area.controller.enabled = False + + last = self._move_info["last_pos"] + + # new - last + # pointer move events are in viewport or canvas space + delta = Vector3(ev.x - last[0], ev.y - last[1]) + + 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 + # also since these are just deltas we don't have to calculate positions relative to the viewport + delta_ndc = delta.multiply( + Vector3( + 2 / viewport_size[0], + -2 / viewport_size[1], + 0 + ) + ) + + camera = self._plot_area.camera + + # current world position + vec = self.position.clone() + + # compute and add delta in projected NDC space and then unproject back to world space + vec.project(camera).add(delta_ndc).unproject(camera) + + new_value = getattr(vec, self.axis) + + if new_value < self.limits[0] or new_value > self.limits[1]: + return + + self.value = new_value + self._plot_area.controller.enabled = True + + def _move_end(self, ev): + self._move_info = None + self._plot_area.controller.enabled = True - self.slider = slider - self.slider.observe(self.set_position, "value") + def _pointer_enter(self, ev): + self.line_outer.geometry.colors.data[:] = np.repeat([pygfx.Color("magenta")], 2, axis=0) + self.line_outer.geometry.colors.update_range() - super().__init__(name=name) + def _pointer_leave(self, ev): + if self._move_info is not None: + return - def set_position(self, change): - self.position.x = change["new"] + self._reset_color() - # def _add_plot_area_hook(self, viewport, camera): - # self.gizmo = TransformGizmo(self.world_object) - # self.gizmo.add_default_event_handlers(viewport, camera) + def _reset_color(self): + self.line_outer.geometry.colors.data[:] = self.colors_outer + self.line_outer.geometry.colors.update_range() From d7861df1f6e817889eb27e15708a8bbe5ded0519 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 04:50:11 -0400 Subject: [PATCH 06/17] lineslider events works --- fastplotlib/graphics/line_slider.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/fastplotlib/graphics/line_slider.py b/fastplotlib/graphics/line_slider.py index 74baf20d9..f3870d266 100644 --- a/fastplotlib/graphics/line_slider.py +++ b/fastplotlib/graphics/line_slider.py @@ -22,13 +22,14 @@ class SliderValueFeature(GraphicFeature): **pick info** - +-----------------+----------------------------------------------------------------+ - | key | value | - +=================+================================================================+ - | "new_data" | the new slider position in world coordinates | - | "graphic_index" | the graphic data index that corresponds to the slider position | - | "world_object" | parent world object | - +-----------------+----------------------------------------------------------------+ + +------------------+----------------------------------------------------------------+ + | key | value | + +==================+================================================================+ + | "new_data" | the new slider position in world coordinates | + | "selected_index" | the graphic data index that corresponds to the slider position | + | "world_object" | parent world object | + | "graphic" | LineSlider instance | + +------------------+----------------------------------------------------------------+ """ def __init__(self, parent, axis: str, value: float): @@ -59,7 +60,8 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): "collection-index": self._collection_index, "world_object": self._parent.world_object, "new_data": new_data, - "graphic_index": g_ix + "selected_index": g_ix, + "graphic": self._parent } event_data = FeatureEvent(type="slider", pick_info=pick_info) @@ -189,6 +191,7 @@ def _setup_ipywidget_slider(self, widget): widget.value = int(self.value()) widget.observe(self._ipywidget_callback, "value") self.value.add_event_handler(self._update_ipywidget) + self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize") def _update_ipywidget(self, ev): # update the ipywidget slider value when LineSlider value changes @@ -203,6 +206,11 @@ def _ipywidget_callback(self, change): self.value = change["new"] + def _set_slider_layout(self, *args): + w, h = self._plot_area.renderer.logical_size + + self.ipywidget_slider.layout = ipywidgets.Layout(width=f"{w}px") + def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): """ Makes and returns an ipywidget slider that is associated to this LineSlider @@ -271,9 +279,9 @@ def get_selected_index(self, graphic: Graphic = None) -> int: idx = np.searchsorted(to_search, find_value, side="left") if idx > 0 and (idx == len(to_search) or math.fabs(find_value - to_search[idx - 1]) < math.fabs(find_value - to_search[idx])): - return idx - 1 + return int(idx - 1) else: - return idx + return int(idx) def _get_source(self, graphic): if self.parent is None and graphic is None: From c0a61035d38c31a5c263895718364ec13bc65cb0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 06:13:33 -0400 Subject: [PATCH 07/17] docstrings --- fastplotlib/graphics/line_slider.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/fastplotlib/graphics/line_slider.py b/fastplotlib/graphics/line_slider.py index f3870d266..2db4b5deb 100644 --- a/fastplotlib/graphics/line_slider.py +++ b/fastplotlib/graphics/line_slider.py @@ -17,19 +17,19 @@ class SliderValueFeature(GraphicFeature): + # A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system """ - A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system + Manages the slider value and callbacks **pick info** - +------------------+----------------------------------------------------------------+ - | key | value | - +==================+================================================================+ - | "new_data" | the new slider position in world coordinates | - | "selected_index" | the graphic data index that corresponds to the slider position | - | "world_object" | parent world object | - | "graphic" | LineSlider instance | - +------------------+----------------------------------------------------------------+ + ================== ================================================================ + key value + ================== ================================================================ + "new_data" the new slider position in world coordinates + "selected_index" the graphic data index that corresponds to the slider position + "world_object" parent world object + ================== ================================================================ """ def __init__(self, parent, axis: str, value: float): @@ -110,10 +110,11 @@ def __init__( Features -------- - value: SliderValueFeature - | value() returns the current slider position in world coordinates - | use value.add_event_handler() to add callback functions that are called - when the LineSlider value changes. See feaure class for event pick_info table + + value: :class:`SliderValueFeature` + ``value()`` returns the current slider position in world coordinates + use ``value.add_event_handler()`` to add callback functions that are + called when the LineSlider value changes. See feaure class for event pick_info table """ From b9902d923bada24b86abf77c5cb1eaa3a1bb1942 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 23:43:23 -0400 Subject: [PATCH 08/17] rename _linear to _linear_region --- fastplotlib/graphics/selectors/{_linear.py => _linear_region.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename fastplotlib/graphics/selectors/{_linear.py => _linear_region.py} (100%) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear_region.py similarity index 100% rename from fastplotlib/graphics/selectors/_linear.py rename to fastplotlib/graphics/selectors/_linear_region.py From 4cd3b359abc709be6ef5a9353757a7c80f4619b6 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 23:43:52 -0400 Subject: [PATCH 09/17] rename --- fastplotlib/graphics/selectors/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 552b938ef..42e48b9c2 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -1 +1,2 @@ -from ._linear import LinearSelector +from ._linear_region import LinearRegionSelector + From 853acfe9badeda143c7cc7778a702dd704116ca0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 23:45:44 -0400 Subject: [PATCH 10/17] move line_slider to selectors/_linear --- fastplotlib/graphics/{line_slider.py => selectors/_linear.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename fastplotlib/graphics/{line_slider.py => selectors/_linear.py} (100%) diff --git a/fastplotlib/graphics/line_slider.py b/fastplotlib/graphics/selectors/_linear.py similarity index 100% rename from fastplotlib/graphics/line_slider.py rename to fastplotlib/graphics/selectors/_linear.py From 03125dd3f3021cabceaa231c74b5bfb79ebf4ddd Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 17 Apr 2023 23:47:28 -0400 Subject: [PATCH 11/17] rename LineSlider -> LinearSelector --- fastplotlib/graphics/selectors/_linear.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 2db4b5deb..2e4bfe7ca 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -12,8 +12,8 @@ except: HAS_IPYWIDGETS = False -from ._base import Graphic, GraphicFeature -from .features._base import FeatureEvent +from .._base import Graphic, GraphicFeature +from ..features._base import FeatureEvent class SliderValueFeature(GraphicFeature): @@ -69,7 +69,7 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): self._call_event_handlers(event_data) -class LineSlider(Graphic): +class LinearSelector(Graphic): def __init__( self, value: int, @@ -114,7 +114,7 @@ def __init__( value: :class:`SliderValueFeature` ``value()`` returns the current slider position in world coordinates use ``value.add_event_handler()`` to add callback functions that are - called when the LineSlider value changes. See feaure class for event pick_info table + called when the LinearSelector value changes. See feaure class for event pick_info table """ @@ -140,7 +140,7 @@ def __init__( self.axis = axis - super(LineSlider, self).__init__(name=name) + super(LinearSelector, self).__init__(name=name) if thickness < 1.1: material = pygfx.LineThinMaterial @@ -188,20 +188,20 @@ def __init__( self._block_ipywidget_call = False def _setup_ipywidget_slider(self, widget): - # setup ipywidget slider with callbacks to this LineSlider + # setup ipywidget slider with callbacks to this LinearSelector widget.value = int(self.value()) widget.observe(self._ipywidget_callback, "value") self.value.add_event_handler(self._update_ipywidget) self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize") def _update_ipywidget(self, ev): - # update the ipywidget slider value when LineSlider value changes + # update the ipywidget slider value when LinearSelector value changes self._block_ipywidget_call = True self.ipywidget_slider.value = int(ev.pick_info["new_data"]) self._block_ipywidget_call = False def _ipywidget_callback(self, change): - # update the LineSlider if the ipywidget value changes + # update the LinearSelector if the ipywidget value changes if self._block_ipywidget_call: return @@ -214,7 +214,7 @@ def _set_slider_layout(self, *args): def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): """ - Makes and returns an ipywidget slider that is associated to this LineSlider + Makes and returns an ipywidget slider that is associated to this LinearSelector Parameters ---------- From 6728939c1746b4b6585c52b20c578f4a7bc5179b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 18 Apr 2023 01:59:38 -0400 Subject: [PATCH 12/17] can sync selectors, made add_linear_selector() --- fastplotlib/graphics/_base.py | 10 +++ fastplotlib/graphics/line.py | 30 +++++++- fastplotlib/graphics/selectors/__init__.py | 2 +- fastplotlib/graphics/selectors/_linear.py | 90 ++++++++++++++-------- fastplotlib/layouts/_base.py | 15 ++-- 5 files changed, 107 insertions(+), 40 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 309b68d9f..457175bd7 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -113,6 +113,16 @@ def __repr__(self): else: return rval + def __eq__(self, other): + # This is necessary because we use Graphics as weakref proxies + if not isinstance(other, Graphic): + raise TypeError("`==` operator is only valid between two Graphics") + + if self.loc == other.loc: + return True + + return False + def __del__(self): del WORLD_OBJECTS[self.loc] diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index aad1d337f..cd1d14bfe 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 LinearRegionSelector +from .selectors import LinearRegionSelector, LinearSelector from ..utils import make_colors @@ -99,6 +99,32 @@ def __init__( if z_position is not None: self.world_object.position.z = z_position + def add_linear_selector(self, selection: int = None, padding: float = 50, **kwargs): + bounds_init, limits, size, origin, axis = self._get_linear_selector_init_args(padding, **kwargs) + + if selection is None: + selection = limits[0] + + if selection < limits[0] or selection > limits[1]: + raise ValueError(f"the passed selection: {selection} is beyond the limits: {limits}") + + if axis == "x": + end_points = (self.data()[:, 1].min() - padding, self.data()[:, 1].max() + padding) + else: + end_points = (self.data()[:, 0].min() - padding, self.data()[:, 0].max() + padding) + + selector = LinearSelector( + selection=selection, + limits=limits, + end_points=end_points, + parent=self + ) + + self._plot_area.add_graphic(selector, center=False) + selector.position.z = self.position.z + 1 + + return weakref.proxy(selector) + def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> LinearRegionSelector: """ Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, @@ -178,7 +204,7 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): # initial bounds are 20% of the limits range bounds_init = (limits[0], int(np.ptp(limits) * 0.2) + offset) - return bounds_init, limits, size, origin + return bounds_init, limits, size, origin, axis def _add_plot_area_hook(self, plot_area): self._plot_area = plot_area diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 42e48b9c2..6d5591fb6 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -1,2 +1,2 @@ +from ._linear import LinearSelector from ._linear_region import LinearRegionSelector - diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 2e4bfe7ca..c28983113 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -16,24 +16,25 @@ from ..features._base import FeatureEvent -class SliderValueFeature(GraphicFeature): +class LinearSelectionFeature(GraphicFeature): # A bit much to have a class for this but this allows it to integrate with the fastplotlib callback system """ - Manages the slider value and callbacks + Manages the slider selection and callbacks **pick info** ================== ================================================================ - key value + key selection ================== ================================================================ - "new_data" the new slider position in world coordinates + "graphic" the selection graphic "selected_index" the graphic data index that corresponds to the slider position - "world_object" parent world object + "new_data" the new slider position in world coordinates + "delta" the delta vector of the graphic in NDC ================== ================================================================ """ def __init__(self, parent, axis: str, value: float): - super(SliderValueFeature, self).__init__(parent, data=value) + super(LinearSelectionFeature, self).__init__(parent, data=value) self.axis = axis @@ -55,13 +56,19 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): else: g_ix = None + # get pygfx event and reset it + pygfx_ev = self._parent._pygfx_event + self._parent._pygfx_event = None + pick_info = { "index": None, "collection-index": self._collection_index, "world_object": self._parent.world_object, "new_data": new_data, "selected_index": g_ix, - "graphic": self._parent + "graphic": self._parent, + "delta": self._parent.delta, + "pygfx_event": pygfx_ev } event_data = FeatureEvent(type="slider", pick_info=pick_info) @@ -70,9 +77,11 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): class LinearSelector(Graphic): + feature_events = ("selection") + def __init__( self, - value: int, + selection: int, limits: Tuple[int, int], axis: str = "x", parent: Graphic = None, @@ -87,12 +96,12 @@ def __init__( Parameters ---------- + selection: int + initial x or y selected value for the slider + axis: str, default "x" "x" | "y", the axis which the slider can move along - origin: int - the initial position of the slider, x or y value depending on "axis" argument - end_points: (int, int) set length of slider by bounding it between two x-pos or two y-pos @@ -103,7 +112,7 @@ def __init__( thickness of the slider color: Any, default "w" - value to set the color of the slider + selection to set the color of the slider name: str, optional name of line slider @@ -111,15 +120,15 @@ def __init__( Features -------- - value: :class:`SliderValueFeature` - ``value()`` returns the current slider position in world coordinates - use ``value.add_event_handler()`` to add callback functions that are - called when the LinearSelector value changes. See feaure class for event pick_info table + selection: :class:`LinearSelectionFeature` + ``selection()`` returns the current slider position in world coordinates + use ``selection.add_event_handler()`` to add callback functions that are + called when the LinearSelector selection changes. See feaure class for event pick_info table """ self.limits = tuple(map(round, limits)) - value = round(value) + selection = round(selection) if axis == "x": xs = np.zeros(2) @@ -171,10 +180,12 @@ def __init__( self._set_world_object(world_object) # set x or y position - pos = getattr(self.position, axis) - pos = value + if axis == "x": + self.position.x = selection + else: + self.position.y = selection - self.value = SliderValueFeature(self, axis=axis, value=value) + self.selection = LinearSelectionFeature(self, axis=axis, value=selection) self.ipywidget_slider = ipywidget_slider @@ -182,6 +193,8 @@ def __init__( self._setup_ipywidget_slider(ipywidget_slider) self._move_info: dict = None + self.delta = None + self._pygfx_event = None self.parent = parent @@ -189,9 +202,9 @@ def __init__( def _setup_ipywidget_slider(self, widget): # setup ipywidget slider with callbacks to this LinearSelector - widget.value = int(self.value()) + widget.value = int(self.selection()) widget.observe(self._ipywidget_callback, "value") - self.value.add_event_handler(self._update_ipywidget) + self.selection.add_event_handler(self._update_ipywidget) self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize") def _update_ipywidget(self, ev): @@ -205,7 +218,7 @@ def _ipywidget_callback(self, change): if self._block_ipywidget_call: return - self.value = change["new"] + self.selection = change["new"] def _set_slider_layout(self, *args): w, h = self._plot_area.renderer.logical_size @@ -240,7 +253,7 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): slider = cls( min=self.limits[0], max=self.limits[1], - value=int(self.value()), + value=int(self.selection()), step=1, **kwargs ) @@ -274,7 +287,7 @@ def get_selected_index(self, graphic: Graphic = None) -> int: to_search = graphic.data()[:, 1] offset = getattr(graphic.position, self.axis) - find_value = self.value() - offset + find_value = self.selection() - offset # get closest data index to the world space position of the slider idx = np.searchsorted(to_search, find_value, side="left") @@ -327,9 +340,9 @@ def _move_to_pointer(self, ev): return if self.axis == "x": - self.value = world_pos.x + self.selection = world_pos.x else: - self.value = world_pos.y + self.selection = world_pos.y def _move_start(self, ev): self._move_info = {"last_pos": (ev.x, ev.y)} @@ -346,13 +359,30 @@ def _move(self, ev): # pointer move events are in viewport or canvas space delta = Vector3(ev.x - last[0], ev.y - last[1]) + self._pygfx_event = ev + + self._move_graphic(delta) + self._move_info = {"last_pos": (ev.x, ev.y)} + self._plot_area.controller.enabled = True + + def _move_graphic(self, delta: Vector3): + """ + Moves the graphic, updates SelectionFeature + + Parameters + ---------- + delta_ndc: Vector3 + the delta by which to move this Graphic, in screen coordinates + + """ + self.delta = delta.clone() viewport_size = self._plot_area.viewport.logical_size # convert delta to NDC coordinates using viewport size # also since these are just deltas we don't have to calculate positions relative to the viewport - delta_ndc = delta.multiply( + delta_ndc = delta.clone().multiply( Vector3( 2 / viewport_size[0], -2 / viewport_size[1], @@ -373,8 +403,8 @@ def _move(self, ev): if new_value < self.limits[0] or new_value > self.limits[1]: return - self.value = new_value - self._plot_area.controller.enabled = True + self.selection = new_value + self.delta = None def _move_end(self, ev): self._move_info = None diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index 69f77ba86..3408b3a82 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -9,7 +9,7 @@ from wgpu.gui.auto import WgpuCanvas from ..graphics._base import Graphic, GraphicCollection -from ..graphics.line_slider import LineSlider +from ..graphics.selectors import LinearSelector # dict to store Graphic instances @@ -82,7 +82,7 @@ def __init__( self._graphics: List[str] = list() # hacky workaround for now to exclude from bbox calculations - self._sliders: List[LineSlider] = list() + self._selectors = list() self.name = name @@ -212,9 +212,9 @@ def add_graphic(self, graphic: Graphic, center: bool = True): if graphic.name is not None: # skip for those that have no name self._check_graphic_name_exists(graphic.name) - # TODO: need to refactor LineSlider entirely - if isinstance(graphic, LineSlider): - self._sliders.append(graphic) # don't manage garbage collection of LineSliders for now + # TODO: need to refactor LinearSelector entirely + if isinstance(graphic, LinearSelector): + self._selectors.append(graphic) # don't manage garbage collection of LineSliders for now else: # store in GRAPHICS dict loc = graphic.loc @@ -293,7 +293,8 @@ def auto_scale(self, maintain_aspect: bool = False, zoom: float = 0.8): in the scene will fill the entire canvas. """ # hacky workaround for now until I figure out how to put it in its own scene - for slider in self._sliders: + # TODO: remove all selectors from a scene to calculate scene bbox + for slider in self._selectors: self.scene.remove(slider.world_object) self.center_scene() @@ -306,7 +307,7 @@ def auto_scale(self, maintain_aspect: bool = False, zoom: float = 0.8): else: width, height, depth = (1, 1, 1) - for slider in self._sliders: + for slider in self._selectors: self.scene.add(slider.world_object) self.camera.width = width From a04a687362a43fca3bd2c192d3db67afc0bd905b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 18 Apr 2023 02:23:52 -0400 Subject: [PATCH 13/17] can sync selectors, can remove them from synchronizer too --- fastplotlib/graphics/features/_base.py | 2 +- fastplotlib/graphics/selectors/_sync.py | 83 +++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 fastplotlib/graphics/selectors/_sync.py diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 94990fd0a..1119bed6b 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -122,7 +122,7 @@ def remove_event_handler(self, handler: callable): if handler not in self._event_handlers: raise KeyError(f"event handler {handler} not registered.") - self._event_handlers.pop(handler) + self._event_handlers.remove(handler) #TODO: maybe this can be implemented right here in the base class @abstractmethod diff --git a/fastplotlib/graphics/selectors/_sync.py b/fastplotlib/graphics/selectors/_sync.py new file mode 100644 index 000000000..15cb01726 --- /dev/null +++ b/fastplotlib/graphics/selectors/_sync.py @@ -0,0 +1,83 @@ +from typing import * + +from . import LinearSelector + + +class Synchronizer: + def __init__(self, *selectors: LinearSelector, key_bind: str = "Shift"): + """ + Synchronize the movement of `Selectors`. Selectors will move in sync only when the selected `"key_bind"` is + used during the mouse movement event. Valid key binds are: ``"Control"``, ``"Shift"`` and ``"Alt"``. + If ``key_bind`` is ``None`` then the selectors will always be synchronized. + + Parameters + ---------- + selectors + selectors to synchronize + + key_bind: str, default ``"Shift"`` + one of ``"Control"``, ``"Shift"`` and ``"Alt"`` + """ + self._selectors = list() + self.key_bind = key_bind + + for s in selectors: + self.add(s) + + self.block_event = False + + @property + def selectors(self): + """Selectors managed by the Synchronizer""" + return self._selectors + + def add(self, selector): + """add a selector""" + selector.selection.add_event_handler(self._handle_event) + self._selectors.append(selector) + + def remove(self, selector): + """remove a selector""" + self._selectors.remove(selector) + selector.selection.remove_event_handler(self._handle_event) + + def _handle_event(self, ev): + if self.block_event: + # because infinite recursion + return + + self.block_event = True + + source = ev.pick_info["graphic"] + delta = ev.pick_info["delta"] + pygfx_ev = ev.pick_info["pygfx_event"] + + # only moves when modifier is used + if pygfx_ev is None: + self.block_event = False + return + + if self.key_bind is not None: + if self.key_bind not in pygfx_ev.modifiers: + self.block_event = False + return + + if delta is not None: + self._move_selectors(source, delta) + + self.block_event = False + + def _move_selectors(self, source, delta): + for s in self.selectors: + # must use == and not is to compare Graphics because they are weakref proxies! + if s == source: + # if it's the source, since it has already movied + continue + + s._move_graphic(delta) + + def __del__(self): + for s in self.selectors: + self.remove(s) + + self.selectors.clear() From 4dd9f9e12651f07f20e75005ddb31032c331017e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 18 Apr 2023 03:05:06 -0400 Subject: [PATCH 14/17] works for line collection --- fastplotlib/graphics/line.py | 21 +++--- fastplotlib/graphics/line_collection.py | 82 ++++++++++++++++------ fastplotlib/graphics/selectors/__init__.py | 1 + fastplotlib/graphics/selectors/_linear.py | 18 +++-- 4 files changed, 87 insertions(+), 35 deletions(-) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index cd1d14bfe..7a839c32c 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -100,7 +100,7 @@ def __init__( self.world_object.position.z = z_position def add_linear_selector(self, selection: int = None, padding: float = 50, **kwargs): - bounds_init, limits, size, origin, axis = self._get_linear_selector_init_args(padding, **kwargs) + bounds_init, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) if selection is None: selection = limits[0] @@ -108,16 +108,12 @@ def add_linear_selector(self, selection: int = None, padding: float = 50, **kwar if selection < limits[0] or selection > limits[1]: raise ValueError(f"the passed selection: {selection} is beyond the limits: {limits}") - if axis == "x": - end_points = (self.data()[:, 1].min() - padding, self.data()[:, 1].max() + padding) - else: - end_points = (self.data()[:, 0].min() - padding, self.data()[:, 0].max() + padding) - selector = LinearSelector( selection=selection, limits=limits, end_points=end_points, - parent=self + parent=self, + **kwargs ) self._plot_area.add_graphic(selector, center=False) @@ -145,7 +141,7 @@ def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> Linear """ - bounds_init, limits, size, origin = self._get_linear_selector_init_args(padding, **kwargs) + bounds_init, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) # create selector selector = LinearRegionSelector( @@ -165,6 +161,7 @@ def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> Linear # so we should only work with a proxy on the user-end return weakref.proxy(selector) + # TODO: this method is a bit of a mess, can refactor later def _get_linear_selector_init_args(self, padding: float, **kwargs): # computes initial bounds, limits, size and origin of linear selectors data = self.data() @@ -187,6 +184,10 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): # need y offset too for this origin = (limits[0] - offset, position_y + self.position.y) + + # endpoints of the data range + # used by linear selector but not linear region + end_points = (self.data()[:, 1].min() - padding, self.data()[:, 1].max() + padding) else: offset = self.position.y # y limits @@ -201,10 +202,12 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs): # need x offset too for this origin = (position_x + self.position.x, limits[0] - offset) + end_points = (self.data()[:, 0].min() - padding, self.data()[:, 0].max() + padding) + # initial bounds are 20% of the limits range bounds_init = (limits[0], int(np.ptp(limits) * 0.2) + offset) - return bounds_init, limits, size, origin, axis + return bounds_init, limits, size, origin, axis, end_points def _add_plot_area_hook(self, plot_area): self._plot_area = plot_area diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index f89cc8b37..dc1862214 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 LinearRegionSelector +from .selectors import LinearRegionSelector, LinearSelector from ..utils import make_colors @@ -195,6 +195,28 @@ def __init__( self.add_graphic(lg, reset_index=False) + def add_linear_selector(self, selection: int = None, padding: float = 50, **kwargs): + bounds, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) + + if selection is None: + selection = limits[0] + + if selection < limits[0] or selection > limits[1]: + raise ValueError(f"the passed selection: {selection} is beyond the limits: {limits}") + + selector = LinearSelector( + selection=selection, + limits=limits, + end_points=end_points, + parent=self, + **kwargs + ) + + self._plot_area.add_graphic(selector, center=False) + selector.position.z = self.position.z + 1 + + return weakref.proxy(selector) + def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> LinearRegionSelector: """ Add a ``LinearRegionSelector``. @@ -216,17 +238,38 @@ def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> Linear """ + bounds, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) + + selector = LinearRegionSelector( + 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 _get_linear_selector_init_args(self, padding, **kwargs): bounds_init = list() limits = list() sizes = list() origin = list() + end_points = list() for g in self.graphics: - _bounds_init, _limits, _size, _origin = g._get_linear_selector_init_args(padding=0, **kwargs) + _bounds_init, _limits, _size, _origin, axis, _end_points = \ + g._get_linear_selector_init_args(padding=0, **kwargs) + bounds_init.append(_bounds_init) limits.append(_limits) sizes.append(_size) origin.append(_origin) + end_points.append(_end_points) # set the init bounds using the extents of the collection b = np.vstack(bounds_init) @@ -236,22 +279,29 @@ def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> Linear l = np.vstack(limits) limits = (l[:, 0].min(), l[:, 1].max()) + # stack endpoints + end_points = np.vstack(end_points) + # use the min endpoint for index 0, highest endpoint for index 1 + end_points = [end_points[:, 0].min() - padding, end_points[:, 1].max() + padding] + + # TODO: refactor this to use `LineStack.graphics[-1].position.y` if isinstance(self, LineStack): + stack_offset = self.separation * len(sizes) # sum them if it's a stack size = sum(sizes) - size += self.separation * len(sizes) + # add the separations + size += stack_offset + + # a better way to get the max y value? + # graphics y-position + data y-max + padding + end_points[1] = self.graphics[-1].position.y + self.graphics[-1].data()[:, 1].max() + padding + 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 @@ -261,19 +311,7 @@ def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> Linear origin_x = (o[:, 0].min() + o[:, 0].max()) / 2 origin = (origin_x, limits[0]) - selector = LinearRegionSelector( - 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) + return bounds, limits, size, origin, axis, end_points def _add_plot_area_hook(self, plot_area): self._plot_area = plot_area diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py index 6d5591fb6..c67e15e40 100644 --- a/fastplotlib/graphics/selectors/__init__.py +++ b/fastplotlib/graphics/selectors/__init__.py @@ -1,2 +1,3 @@ from ._linear import LinearSelector from ._linear_region import LinearRegionSelector +from ._sync import Synchronizer diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index c28983113..d4169bd9d 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -12,7 +12,7 @@ except: HAS_IPYWIDGETS = False -from .._base import Graphic, GraphicFeature +from .._base import Graphic, GraphicFeature, GraphicCollection from ..features._base import FeatureEvent @@ -262,7 +262,7 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): return slider - def get_selected_index(self, graphic: Graphic = None) -> int: + def get_selected_index(self, graphic: Graphic = None) -> Union[int, List[int]]: """ Data index the slider is currently at w.r.t. the Graphic data. @@ -273,12 +273,22 @@ def get_selected_index(self, graphic: Graphic = None) -> int: Returns ------- - int - data index the slider is currently at + int or List[int] + data index the slider is currently at, list of ``int`` if a Collection """ graphic = self._get_source(graphic) + if isinstance(graphic, GraphicCollection): + ixs = list() + for g in graphic.graphics: + ixs.append(self._get_selected_index(g)) + + return ixs + + return self._get_selected_index(graphic) + + def _get_selected_index(self, graphic): # the array to search for the closest value along that axis if self.axis == "x": to_search = graphic.data()[:, 0] From 1c5ca1ba091439593bc3540df8e9ec76e84e463e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 18 Apr 2023 03:15:28 -0400 Subject: [PATCH 15/17] docstrings --- fastplotlib/graphics/line.py | 22 ++++++++++++++++++- fastplotlib/graphics/line_collection.py | 26 +++++++++++++++++++++-- fastplotlib/graphics/selectors/_linear.py | 3 ++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 7a839c32c..9a1fb1cb6 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -99,7 +99,27 @@ def __init__( if z_position is not None: self.world_object.position.z = z_position - def add_linear_selector(self, selection: int = None, padding: float = 50, **kwargs): + def add_linear_selector(self, selection: int = None, padding: float = 50, **kwargs) -> LinearSelector: + """ + Adds a linear selector. + + Parameters + ---------- + selection: int + initial position of the selector + + padding: float + pad the length of the selector + + kwargs + passed to :class:`.LinearSelector` + + Returns + ------- + LinearSelector + + """ + bounds_init, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) if selection is None: diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index dc1862214..da859dd34 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -195,7 +195,29 @@ def __init__( self.add_graphic(lg, reset_index=False) - def add_linear_selector(self, selection: int = None, padding: float = 50, **kwargs): + def add_linear_selector(self, selection: int = None, padding: float = 50, **kwargs) -> LinearSelector: + """ + Adds a :class:`.LinearSelector` . + Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a plot area just like + any other ``Graphic``. + + Parameters + ---------- + selection: int + initial position of the selector + + padding: float + pad the length of the selector + + kwargs + passed to :class:`.LinearSelector` + + Returns + ------- + LinearSelector + + """ + bounds, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) if selection is None: @@ -219,7 +241,7 @@ def add_linear_selector(self, selection: int = None, padding: float = 50, **kwar def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> LinearRegionSelector: """ - Add a ``LinearRegionSelector``. + Add a :class:`.LinearRegionSelector` Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a plot area just like any other ``Graphic``. diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index d4169bd9d..ee597667d 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -79,6 +79,7 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): class LinearSelector(Graphic): feature_events = ("selection") + # TODO: make `selection` arg in graphics data space not world space def __init__( self, selection: int, @@ -97,7 +98,7 @@ def __init__( Parameters ---------- selection: int - initial x or y selected value for the slider + initial x or y selected position for the slider, in world space axis: str, default "x" "x" | "y", the axis which the slider can move along From e48cba83f5b9627ac2c769dee39624e09d4e642c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 18 Apr 2023 03:21:24 -0400 Subject: [PATCH 16/17] update nb --- .../{linear_selector.ipynb => linear_region_selector.ipynb} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename examples/{linear_selector.ipynb => linear_region_selector.ipynb} (99%) diff --git a/examples/linear_selector.ipynb b/examples/linear_region_selector.ipynb similarity index 99% rename from examples/linear_selector.ipynb rename to examples/linear_region_selector.ipynb index 255598ba1..8a3ae6cd0 100644 --- a/examples/linear_selector.ipynb +++ b/examples/linear_region_selector.ipynb @@ -5,7 +5,7 @@ "id": "40bf515f-7ca3-4f16-8ec9-31076e8d4bde", "metadata": {}, "source": [ - "# `LinearSelector` with single lines" + "# `LinearRegionSelector` with single lines" ] }, { From 3969ec015b126f44b5541be3174fd45b2880acbd Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 18 Apr 2023 03:23:29 -0400 Subject: [PATCH 17/17] update nb example --- examples/linear_selector.ipynb | 131 +++++++++++++++++++++++++++++++++ 1 file changed, 131 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..a00225a5f --- /dev/null +++ b/examples/linear_selector.ipynb @@ -0,0 +1,131 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "e0354810-f942-4e4a-b4b9-bb8c083a314e", + "metadata": {}, + "source": [ + "## `LinearSelector`, draggable selector that can optionally associated with an ipywidget." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d79bb7e0-90af-4459-8dcb-a7a21a89ef64", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import fastplotlib as fpl\n", + "from fastplotlib.graphics.selectors import Synchronizer\n", + "\n", + "import numpy as np\n", + "from ipywidgets import VBox\n", + "\n", + "plot = fpl.Plot()\n", + "\n", + "# data to plot\n", + "xs = np.linspace(0, 100, 1000)\n", + "sine = np.sin(xs) * 20\n", + "\n", + "# make sine along x axis\n", + "sine_graphic = plot.add_line(np.column_stack([xs, sine]).astype(np.float32))\n", + "\n", + "# make some selectors\n", + "selector = sine_graphic.add_linear_selector()\n", + "selector2 = sine_graphic.add_linear_selector(20)\n", + "selector3 = sine_graphic.add_linear_selector(40)\n", + "\n", + "ss = Synchronizer(selector, selector2, selector3)\n", + "\n", + "def set_color_at_index(ev):\n", + " # changes the color at the index where the slider is\n", + " ix = ev.pick_info[\"selected_index\"]\n", + " g = ev.pick_info[\"graphic\"].parent\n", + " g.colors[ix] = \"green\"\n", + "\n", + "selector.selection.add_event_handler(set_color_at_index)\n", + "\n", + "# fastplotlib LineSelector can make an ipywidget slider and return it :D \n", + "ipywidget_slider = selector.make_ipywidget_slider()\n", + "\n", + "plot.auto_scale()\n", + "plot.show()\n", + "VBox([plot.show(), ipywidget_slider])" + ] + }, + { + "cell_type": "markdown", + "id": "2c49cdc2-0555-410c-ae2e-da36c3bf3bf0", + "metadata": {}, + "source": [ + "### Drag linear selectors with the mouse, hold \"Shift\" to synchronize movement of all the selectors" + ] + }, + { + "cell_type": "markdown", + "id": "69057edd-7e23-41e7-a284-ac55df1df5d9", + "metadata": {}, + "source": [ + "## Also works for line collections" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1a3b98bd-7139-48d9-bd70-66c500cd260d", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "sines = [sine] * 10\n", + "\n", + "plot = fpl.Plot()\n", + "\n", + "sine_stack = plot.add_line_stack(sines)\n", + "\n", + "colors = \"y\", \"blue\", \"red\", \"green\"\n", + "\n", + "selectors = list()\n", + "for i, c in enumerate(colors):\n", + " sel = sine_stack.add_linear_selector(i * 100, color=c, name=str(i))\n", + " selectors.append(sel)\n", + " \n", + "ss = Synchronizer(*selectors)\n", + "\n", + "plot.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b6c2d9d6-ffe0-484c-a550-cafb44fa8465", + "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 +}