diff --git a/examples/linear_region_selector.ipynb b/examples/linear_region_selector.ipynb new file mode 100644 index 000000000..8a3ae6cd0 --- /dev/null +++ b/examples/linear_region_selector.ipynb @@ -0,0 +1,301 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "40bf515f-7ca3-4f16-8ec9-31076e8d4bde", + "metadata": {}, + "source": [ + "# `LinearRegionSelector` 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/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 +} 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/features/_base.py b/fastplotlib/graphics/features/_base.py index da6a177a0..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 @@ -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..9a1fb1cb6 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, LinearSelector from ..utils import make_colors @@ -96,6 +99,139 @@ 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) -> LinearSelector: + """ + Adds a linear selector. + + Parameters + ---------- + selection: int + initial position of the selector + + padding: float + pad the length of the selector + + kwargs + passed to :class:`.LinearSelector` + + Returns + ------- + LinearSelector + + """ + + bounds_init, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) + + if selection is None: + selection = limits[0] + + if selection < limits[0] or selection > limits[1]: + raise ValueError(f"the passed selection: {selection} is beyond the limits: {limits}") + + selector = LinearSelector( + selection=selection, + limits=limits, + end_points=end_points, + parent=self, + **kwargs + ) + + self._plot_area.add_graphic(selector, center=False) + selector.position.z = self.position.z + 1 + + return weakref.proxy(selector) + + def add_linear_region_selector(self, padding: float = 100.0, **kwargs) -> LinearRegionSelector: + """ + Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage, + remove, or delete them from a plot area just like any other ``Graphic``. + + Parameters + ---------- + padding: float, default 100.0 + Extends the linear selector along the y-axis to make it easier to interact with. + + kwargs + passed to ``LinearRegionSelector`` + + Returns + ------- + LinearRegionSelector + linear selection graphic + + """ + + bounds_init, limits, size, origin, axis, end_points = self._get_linear_selector_init_args(padding, **kwargs) + + # create selector + selector = LinearRegionSelector( + bounds=bounds_init, + limits=limits, + size=size, + origin=origin, + parent=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) + + # TODO: this method is a bit of a mess, can refactor later + def _get_linear_selector_init_args(self, padding: float, **kwargs): + # computes initial bounds, limits, size and origin of linear selectors + data = self.data() + + if "axis" in kwargs.keys(): + axis = kwargs["axis"] + else: + axis = "x" + + if axis == "x": + offset = self.position.x + # x limits + 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) + + # 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 + 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) + + 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, end_points + + 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..da859dd34 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, LinearSelector from ..utils import make_colors -from copy import deepcopy class LineCollection(GraphicCollection, Interaction): @@ -192,6 +195,149 @@ def __init__( self.add_graphic(lg, reset_index=False) + 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: + 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 :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, 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, 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) + 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()) + + # 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) + # 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 + + 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]) + + return bounds, limits, size, origin, axis, end_points + + 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 +492,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/line_slider.py b/fastplotlib/graphics/line_slider.py deleted file mode 100644 index f19db9cda..000000000 --- a/fastplotlib/graphics/line_slider.py +++ /dev/null @@ -1,110 +0,0 @@ -from typing import * - -import numpy as np - -import pygfx -from pygfx import TransformGizmo, Color -from ipywidgets import IntSlider - -from ._base import Graphic - - -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, - thickness: float = 2.5, - color: Any = "w", - name: str = None, - ): - """ - Create a horizontal or vertical line slider that is synced to an ipywidget IntSlider - - 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 - - y_pos: float, optional - y-position of slider - - bounds: 2-element int tuple, optional - 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 - - thickness: float, default 2.5 - thickness of the slider - - color: Any, default "w" - value to set the color of the slider - - name: str, optional - name of line slider - """ - if orientation == "v": - if x_pos is None: - raise ValueError("Must pass `x_pos` if orientation is 'v'") - - xs = np.zeros(2) - ys = np.array([bounds[0], bounds[1]]) - 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'") - - else: - raise ValueError("`orientation` must be one of 'v' or 'h'") - - 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) - - line_inner = pygfx.Line( - # self.data.feature_data because data is a Buffer - geometry=pygfx.Geometry(positions=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) - ) - - world_object = pygfx.Group() - - world_object.add(line_outer) - world_object.add(line_inner) - - self._set_world_object(world_object) - - self.position.x = x_pos - - self.slider = slider - self.slider.observe(self.set_position, "value") - - super().__init__(name=name) - - def set_position(self, change): - self.position.x = change["new"] - - # def _add_plot_area_hook(self, viewport, camera): - # self.gizmo = TransformGizmo(self.world_object) - # self.gizmo.add_default_event_handlers(viewport, camera) diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py new file mode 100644 index 000000000..c67e15e40 --- /dev/null +++ b/fastplotlib/graphics/selectors/__init__.py @@ -0,0 +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 new file mode 100644 index 000000000..ee597667d --- /dev/null +++ b/fastplotlib/graphics/selectors/_linear.py @@ -0,0 +1,436 @@ +from typing import * +import math + +import numpy as np + +import pygfx +from pygfx.linalg import Vector3 + +try: + import ipywidgets + HAS_IPYWIDGETS = True +except: + HAS_IPYWIDGETS = False + +from .._base import Graphic, GraphicFeature, GraphicCollection +from ..features._base import FeatureEvent + + +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 selection and callbacks + + **pick info** + + ================== ================================================================ + key selection + ================== ================================================================ + "graphic" the selection graphic + "selected_index" the graphic data index that corresponds to the slider position + "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(LinearSelectionFeature, 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 + + # 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, + "delta": self._parent.delta, + "pygfx_event": pygfx_ev + } + + event_data = FeatureEvent(type="slider", pick_info=pick_info) + + self._call_event_handlers(event_data) + + +class LinearSelector(Graphic): + feature_events = ("selection") + + # TODO: make `selection` arg in graphics data space not world space + def __init__( + self, + selection: 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, + ): + """ + Create a horizontal or vertical line slider that is synced to an ipywidget IntSlider + + Parameters + ---------- + selection: int + 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 + + end_points: (int, int) + set length of slider by bounding it between two x-pos or two y-pos + + ipywidget_slider: IntSlider, optional + ipywidget slider to associate with this graphic + + thickness: float, default 2.5 + thickness of the slider + + color: Any, default "w" + selection to set the color of the slider + + name: str, optional + name of line slider + + Features + -------- + + 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)) + selection = round(selection) + + if axis == "x": + xs = np.zeros(2) + ys = np.array(end_points) + zs = np.zeros(2) + + 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("`axis` must be one of 'v' or 'h'") + + line_data = line_data.astype(np.float32) + + self.axis = axis + + super(LinearSelector, self).__init__(name=name) + + if thickness < 1.1: + material = pygfx.LineThinMaterial + else: + material = pygfx.LineMaterial + + colors_inner = np.repeat([pygfx.Color(color)], 2, axis=0).astype(np.float32) + self.colors_outer = np.repeat([pygfx.Color([0.3, 0.3, 0.3, 1.0])], 2, axis=0).astype(np.float32) + + line_inner = pygfx.Line( + # self.data.feature_data because data is a Buffer + geometry=pygfx.Geometry(positions=line_data, colors=colors_inner), + material=material(thickness=thickness, vertex_colors=True) + ) + + 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(self.line_outer) + world_object.add(line_inner) + + self._set_world_object(world_object) + + # set x or y position + if axis == "x": + self.position.x = selection + else: + self.position.y = selection + + self.selection = LinearSelectionFeature(self, axis=axis, value=selection) + + self.ipywidget_slider = ipywidget_slider + + if self.ipywidget_slider is not None: + self._setup_ipywidget_slider(ipywidget_slider) + + self._move_info: dict = None + self.delta = None + self._pygfx_event = None + + self.parent = parent + + self._block_ipywidget_call = False + + def _setup_ipywidget_slider(self, widget): + # setup ipywidget slider with callbacks to this LinearSelector + widget.value = int(self.selection()) + widget.observe(self._ipywidget_callback, "value") + 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): + # 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 LinearSelector if the ipywidget value changes + if self._block_ipywidget_call: + return + + self.selection = 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 LinearSelector + + 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.selection()), + step=1, + **kwargs + ) + self.ipywidget_slider = slider + self._setup_ipywidget_slider(slider) + + return slider + + 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. + + Parameters + ---------- + graphic: Graphic, optional + Graphic to get the selected data index from. Default is the parent graphic associated to the slider. + + Returns + ------- + 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] + offset = getattr(graphic.position, self.axis) + else: + to_search = graphic.data()[:, 1] + offset = getattr(graphic.position, self.axis) + + find_value = self.selection() - offset + + # get closest data index to the world space position of the slider + idx = np.searchsorted(to_search, find_value, side="left") + + if idx > 0 and (idx == len(to_search) or math.fabs(find_value - to_search[idx - 1]) < math.fabs(find_value - to_search[idx])): + return int(idx - 1) + else: + return int(idx) + + def _get_source(self, graphic): + if self.parent is None and graphic is None: + raise AttributeError( + "No Graphic to apply selector. " + "You must either set a ``parent`` Graphic on the selector, or pass a graphic." + ) + + # use passed graphic if provided, else use parent + if graphic is not None: + source = graphic + else: + source = self.parent + + return source + + def _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.selection = world_pos.x + else: + self.selection = 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._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.clone().multiply( + Vector3( + 2 / viewport_size[0], + -2 / viewport_size[1], + 0 + ) + ) + + camera = self._plot_area.camera + + # current world position + vec = self.position.clone() + + # compute and add delta in projected NDC space and then unproject back to world space + vec.project(camera).add(delta_ndc).unproject(camera) + + new_value = getattr(vec, self.axis) + + if new_value < self.limits[0] or new_value > self.limits[1]: + return + + self.selection = new_value + self.delta = None + + def _move_end(self, ev): + self._move_info = None + self._plot_area.controller.enabled = True + + def _pointer_enter(self, ev): + self.line_outer.geometry.colors.data[:] = np.repeat([pygfx.Color("magenta")], 2, axis=0) + self.line_outer.geometry.colors.update_range() + + def _pointer_leave(self, ev): + if self._move_info is not None: + return + + self._reset_color() + + def _reset_color(self): + self.line_outer.geometry.colors.data[:] = self.colors_outer + self.line_outer.geometry.colors.update_range() diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py new file mode 100644 index 000000000..8f68a754a --- /dev/null +++ b/fastplotlib/graphics/selectors/_linear_region.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/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() diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index ea24d87b1..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 @@ -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() @@ -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 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