import math
from numbers import Real
from typing import Sequence
import numpy as np
import pygfx
from .._base import Graphic
from .._collection_base import GraphicCollection
from .._features._selection_features import LinearSelectionFeature
from ._base_selector import BaseSelector
[docs]
class LinearSelector(BaseSelector):
@property
def parent(self) -> Graphic:
return self._parent
@property
def selection(self) -> float:
"""
x or y value of selector's current position
"""
return self._selection.value
@selection.setter
def selection(self, value: int):
graphic = self._parent
if isinstance(graphic, GraphicCollection):
pass
self._selection.set_value(self, value)
@property
def limits(self) -> tuple[float, float]:
return self._limits
@limits.setter
def limits(self, values: tuple[float, float]):
# check that `values` is an iterable of two real numbers
# using `Real` here allows it to work with builtin `int` and `float` types, and numpy scaler types
if len(values) != 2 or not all(map(lambda v: isinstance(v, Real), values)):
raise TypeError("limits must be an iterable of two numeric values")
self._limits = tuple(
map(round, values)
) # if values are close to zero things get weird so round them
self.selection._limits = self._limits
@property
def edge_color(self) -> pygfx.Color:
"""Returns the color of the linear selector."""
return self._edge_color
@edge_color.setter
def edge_color(self, color: str | Sequence[float]):
"""
Set the color of the linear selector.
Parameters
----------
color : str | Sequence[float]
String or sequence of floats that gets converted into a ``pygfx.Color`` object.
"""
color = pygfx.Color(color)
# only want to change inner line color
self._edges[0].material.color = color
self._original_colors[self._edges[0]] = color
self._edge_color = color
# TODO: make `selection` arg in graphics data space not world space
def __init__(
self,
selection: float,
limits: Sequence[float],
size: float,
center: float,
axis: str = "x",
parent: Graphic = None,
edge_color: str | Sequence[float] | np.ndarray = "w",
thickness: float = 2.5,
arrow_keys_modifier: str = "Shift",
name: str = None,
):
"""
Create a horizontal or vertical line that can be used to select a value along an axis.
Parameters
----------
selection: int
initial x or y selected position for the selector, in data space
limits: (int, int)
(min, max) limits along the x or y-axis for the selector, in data space
size: float
size of the selector, usually the range of the data
center: float
center offset of the selector on the orthogonal axis, usually the data mean
axis: str, default "x"
"x" | "y", the axis along which the selector can move
parent: Graphic
parent graphic for this LinearSelector
arrow_keys_modifier: str
modifier key that must be pressed to initiate movement using arrow keys, must be one of:
"Control", "Shift", "Alt" or ``None``. Double-click the selector first to enable the
arrow key movements, or set the attribute ``arrow_key_events_enabled = True``
thickness: float, default 2.5
thickness of the selector
edge_color: str | tuple | np.ndarray, default "w"
color of the selector
name: str, optional
name of linear selector
"""
self._fill_color = None
self._edge_color = pygfx.Color(edge_color)
self._vertex_color = None
if len(limits) != 2:
raise ValueError("limits must be a tuple of 2 integers, i.e. (int, int)")
self._limits = np.asarray(limits)
end_points = [-size / 2, size / 2]
if axis == "x":
xs = np.array([selection, selection])
ys = np.array(end_points)
zs = np.zeros(2)
line_data = np.column_stack([xs, ys, zs])
elif axis == "y":
xs = np.array(end_points)
ys = np.array([selection, selection])
zs = np.zeros(2)
line_data = np.column_stack([xs, ys, zs])
else:
raise ValueError("`axis` must be one of 'x' or 'y'")
line_data = line_data.astype(np.float32)
if thickness < 1.1:
material = pygfx.LineThinMaterial
else:
material = pygfx.LineMaterial
self.colors_outer = pygfx.Color([0.3, 0.3, 0.3, 1.0])
line_inner = pygfx.Line(
# self.data.feature_data because data is a Buffer
geometry=pygfx.Geometry(positions=line_data),
material=material(thickness=thickness, color=edge_color, pick_write=True),
)
self.line_outer = pygfx.Line(
geometry=pygfx.Geometry(positions=line_data),
material=material(
thickness=thickness + 6, color=self.colors_outer, pick_write=True
),
)
line_inner.world.z = self.line_outer.world.z + 1
world_object = pygfx.Group()
world_object.add(self.line_outer)
world_object.add(line_inner)
self._move_info: dict = None
if axis == "x":
offset = (parent.offset[0], center + parent.offset[1], 0)
elif axis == "y":
offset = (center + parent.offset[0], parent.offset[1], 0)
# init base selector
BaseSelector.__init__(
self,
edges=(line_inner, self.line_outer),
hover_responsive=(line_inner, self.line_outer),
arrow_keys_modifier=arrow_keys_modifier,
axis=axis,
parent=parent,
name=name,
offset=offset,
)
self._set_world_object(world_object)
self._selection = LinearSelectionFeature(
axis=axis, value=selection, limits=self._limits
)
if self._parent is not None:
self.selection = selection
else:
self._selection.set_value(self, selection)
[docs]
def get_selected_index(self, graphic: Graphic = None) -> int | list[int]:
"""
Data index the selector is currently at w.r.t. the Graphic data.
With LineGraphic data, the geometry x or y position is not always the data position, for example if plotting
data using np.linspace. Use this to get the data index of the selector.
Parameters
----------
graphic: Graphic, optional
Graphic to get the selected data index from. Default is the parent graphic associated to the selector.
Returns
-------
int or List[int]
data index the selector is currently at, list of ``int`` if a Collection
"""
source = self._get_source(graphic)
if isinstance(source, GraphicCollection):
ixs = list()
for g in source.graphics:
ixs.append(self._get_selected_index(g))
return ixs
return self._get_selected_index(source)
def _get_selected_index(self, graphic):
# the array to search for the closest value along that axis
if self.axis == "x":
data = graphic.data[:, 0]
elif self.axis == "y":
data = graphic.data[:, 1]
if (
"Line" in graphic.__class__.__name__
or "Scatter" in graphic.__class__.__name__
):
# we want to find the index of the data closest to the selector position
find_value = self.selection
# get closest data index to the world space position of the selector
idx = np.searchsorted(data, find_value, side="left")
if idx > 0 and (
idx == len(data)
or math.fabs(find_value - data[idx - 1])
< math.fabs(find_value - data[idx])
):
return round(idx - 1)
else:
return round(idx)
if "Image" in graphic.__class__.__name__:
# indices map directly to grid geometry for image data buffer
index = self.selection
shape = graphic.data[:].shape
if self.axis == "x":
# assume selecting columns
upper_bound = shape[1] - 1
elif self.axis == "y":
# assume selecting rows
upper_bound = shape[0] - 1
return min(round(index), upper_bound)
def _move_graphic(self, delta: np.ndarray):
"""
Moves the graphic
Parameters
----------
delta: np.ndarray
delta in world space
"""
if self.axis == "x":
self.selection = self.selection + delta[0]
else:
self.selection = self.selection + delta[1]