from typing import *
from warnings import warn
import numpy as np
import pygfx
from .selectors import (
LinearRegionSelector,
LinearSelector,
RectangleSelector,
PolygonSelector,
)
from .features import (
Thickness,
DashPattern,
parse_dash_pattern,
)
from ..utils import quick_min_max, global_config
from ._positions_base import PositionsGraphic
from ..utils.types import ColorLike, MultiColorLike, ColormapLike
[docs]
@global_config.register
class LineGraphic(PositionsGraphic):
_features = {
"thickness": Thickness,
"dash_pattern": DashPattern,
}
@global_config.declare(
"thickness", "colors", "cmap", "size_space", "dash_pattern", "thin"
)
def __init__(
self,
data: Any,
thickness: float = 2.0,
colors: ColorLike | MultiColorLike = "w",
cmap: ColormapLike | None = None,
cmap_transform: np.ndarray | Iterable[int | float] | None = None,
cmap_range: tuple[float, float] | None = None,
size_space: Literal["screen", "world", "model"] = "screen",
dash_pattern: str | tuple | list = (),
thin: bool = False,
**kwargs,
):
"""
Create a line Graphic, 2d or 3d
Parameters
----------
data: array-like
Line data to plot. Can provide 1D, 2D, or a 3D data.
| If passing a 1D array, it is used to set the y-values and the x-values are generated as an integer range
from [0, data.size]
| 2D data must be of shape [n_points, 2]. 3D data must be of shape [n_points, 3]
thickness: float, optional, default 2.0
thickness of the line
colors: ColorLike or MultiColorLike, default "w"
specify colors as a single human-readable string, a single RGBA array,
or a Sequence (array, tuple, or list) of strings or RGBA arrays
cmap: ColormapLike, optional
Apply a colormap to the line instead of assigning colors manually, this
overrides any argument passed to "colors". For supported colormaps see the
``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/
cmap_transform: np.ndarray, optional
1D array-like of numerical values, if provided, these values are used to map the colors from the cmap
cmap_range: (float, float), optional
the (min, max) of the cmap_transform mapped onto the colormap, defaults to the transform's own range
size_space: str, default "screen"
coordinate space in which the thickness is expressed ("screen", "world", "model")
dash_pattern: str, tuple, or list, default ()
The dash pattern. May be a matplotlib-style string, one of ``"-", "--", "-.", ":"``
or ``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the
length of strokes and gaps. Ignored when ``thin`` is True.
thin: bool, default False
Use the more performant thin line material, which is always one physical pixel wide.
Thickness, dashing, and anti-aliasing are ignored when True.
**kwargs
passed to :class:`.Graphic`
"""
super().__init__(
data=data,
colors=colors,
cmap=cmap,
cmap_transform=cmap_transform,
cmap_range=cmap_range,
size_space=size_space,
**kwargs,
)
self._thickness = Thickness(thickness)
self._dash_pattern = DashPattern(dash_pattern)
self._thin = bool(thin)
if self._thin and parse_dash_pattern(dash_pattern):
warn(
"`dash_pattern` is ignored when `thin=True`; the thin line material does not "
"support dashing"
)
world_object = pygfx.Line(
geometry=self._make_geo(),
material=self._make_material(),
)
self._set_world_object(world_object)
def _get_material_kwargs(self) -> dict:
# pygfx line material kwargs assembled from the current feature state
kwargs = super()._get_material_kwargs()
kwargs["thickness"] = self.thickness
kwargs["thickness_space"] = self.size_space
kwargs["dash_pattern"] = parse_dash_pattern(self._dash_pattern.value)
return kwargs
def _make_material(self) -> pygfx.LineMaterial:
# create the pygfx material, subclasses override to use a different line material
material_cls = pygfx.LineThinMaterial if self._thin else pygfx.LineMaterial
return material_cls(**self._get_material_kwargs())
@property
def thickness(self) -> float:
"""Get or set the line thickness"""
return self._thickness.value
@thickness.setter
def thickness(self, value: float):
self._thickness.set_value(self, value)
@property
def dash_pattern(self) -> str | tuple | list:
"""
Get or set the dash pattern.
May be a matplotlib-style string, one of ``"-", "--", "-.", ":"`` or
``"solid", "dashed", "dashdot", "dotted"``, or a sequence of floats describing the
length of strokes and gaps. Ignored when ``thin`` is True.
"""
return self._dash_pattern.value
@dash_pattern.setter
def dash_pattern(self, value: str | tuple | list):
if self._thin and parse_dash_pattern(value):
warn(
"`dash_pattern` is ignored when `thin=True`; the thin line material does not "
"support dashing"
)
self._dash_pattern.set_value(self, value)
@property
def thin(self) -> bool:
"""
Get or set whether the line uses the more performant thin line material, which is
always one physical pixel wide. Thickness, dashing, and anti-aliasing are ignored
when True.
"""
return self._thin
@thin.setter
def thin(self, value: bool):
value = bool(value)
if value == self._thin:
return
if value and parse_dash_pattern(self._dash_pattern.value):
warn(
"`dash_pattern` is ignored when `thin=True`; the thin line material does not "
"support dashing"
)
self._thin = value
# thin vs. non-thin is a different pygfx material, so rebuild and swap it in place,
# keeping the same geometry
material = self._make_material()
material.opacity = self.alpha
material.alpha_mode = self.alpha_mode
self.world_object.material = material
[docs]
def create_legend_item(
self,
label: str = None,
dash_pattern_labels: dict[str | tuple, str] = None,
cmap_transform_labels: dict[int, str] = None,
):
"""
Create the :class:`.LineLegendItem` of this line, add it to a legend with ``Legend.add()``.
The item follows the line: when its colors, colormap, thickness or dash pattern change the
item changes with them. Per-vertex colors cannot be represented in a legend.
Parameters
----------
label: str, optional
label of the line in the legend, its ``name`` is used if not provided
dash_pattern_labels: dict, optional
{dash_pattern: label}, the label to use for the line's dash pattern
cmap_transform_labels: dict, optional
{cmap_transform value: label}, the label of each value of a qualitative colormap. A
quantitative colormap is shown as a colorbar instead and needs no labels.
Returns
-------
LineLegendItem
"""
self._check_legend_item()
from ..ui._legend import LineLegendItem
self._legend_item = LineLegendItem(
self,
label=label,
dash_pattern_labels=dash_pattern_labels,
cmap_transform_labels=cmap_transform_labels,
)
return self._legend_item
[docs]
def add_linear_selector(
self, selection: float = None, axis: str = "x", **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: float, optional
selected point on the linear selector, by default the first datapoint on the line.
axis: str, default "x"
axis that the selector resides on
kwargs
passed to :class:`.LinearSelector`
Returns
-------
LinearSelector
"""
bounds_init, limits, size, center = self._get_linear_selector_init_args(
axis, padding=0
)
if selection is None:
selection = bounds_init[0]
selector = LinearSelector(
selection=selection,
limits=limits,
axis=axis,
parent=self,
**kwargs,
)
self._plot_area.add_graphic(selector, center=False)
return selector
[docs]
def add_linear_region_selector(
self,
selection: tuple[float, float] = None,
padding: float = 0.0,
axis: str = "x",
**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
----------
selection: (float, float), optional
the starting bounds of the linear region selector, computed from data if not provided
axis: str, default "x"
axis that the selector resides on
padding: float, default 0.0
Extra padding to extend the linear region selector along the orthogonal axis to make it easier to interact with.
kwargs
passed to ``LinearRegionSelector``
Returns
-------
LinearRegionSelector
linear selection graphic
"""
bounds_init, limits, size, center = self._get_linear_selector_init_args(
axis, padding
)
if selection is None:
selection = bounds_init
# create selector
selector = LinearRegionSelector(
selection=selection,
limits=limits,
size=size,
center=center,
axis=axis,
parent=self,
**kwargs,
)
self._plot_area.add_graphic(selector, center=False)
# 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 selector
[docs]
def add_rectangle_selector(
self,
selection: tuple[float, float, float, float] = None,
**kwargs,
) -> RectangleSelector:
"""
Add a :class:`.RectangleSelector`.
Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a
plot area just like any other ``Graphic``.
Parameters
----------
selection: (float, float, float, float), optional
initial (xmin, xmax, ymin, ymax) of the selection
"""
# computes args to create selectors
n_datapoints = self.data.value.shape[0]
value_25p = int(n_datapoints / 4)
# remove any nans
data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)]
x_axis_vals = data[:, 0]
y_axis_vals = data[:, 1]
ymin = np.floor(y_axis_vals.min()).astype(int)
ymax = np.ceil(y_axis_vals.max()).astype(int)
# default selection is 25% of the image
if selection is None:
selection = (x_axis_vals[0], x_axis_vals[value_25p], ymin, ymax)
# min/max limits
limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5)
selector = RectangleSelector(
selection=selection,
limits=limits,
parent=self,
**kwargs,
)
self._plot_area.add_graphic(selector, center=False)
return selector
[docs]
def add_polygon_selector(
self,
selection: List[tuple[float, float]] = None,
**kwargs,
) -> PolygonSelector:
"""
Add a :class:`.PolygonSelector`.
Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a
plot area just like any other ``Graphic``.
Parameters
----------
selection: list[tuple[float, float]], optional
Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon).
"""
# remove any nans
data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)]
x_axis_vals = data[:, 0]
y_axis_vals = data[:, 1]
ymin = np.floor(y_axis_vals.min()).astype(int)
ymax = np.ceil(y_axis_vals.max()).astype(int)
# min/max limits
limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5)
selector = PolygonSelector(
selection,
limits,
parent=self,
**kwargs,
)
self._plot_area.add_graphic(selector, center=False)
return selector
# TODO: this method is a bit of a mess, can refactor later
def _get_linear_selector_init_args(
self, axis: str, padding
) -> tuple[tuple[float, float], tuple[float, float], float, float]:
# computes args to create selectors
n_datapoints = self.data.value.shape[0]
value_25p = int(n_datapoints / 4)
# remove any nans
data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)]
if axis == "x":
# xvals
axis_vals = data[:, 0]
# yvals to get size and center
magn_vals = data[:, 1]
elif axis == "y":
axis_vals = data[:, 1]
magn_vals = data[:, 0]
bounds_init = axis_vals[0], axis_vals[value_25p]
limits = axis_vals[0], axis_vals[-1]
# width or height of selector
size = int(np.ptp(magn_vals) * 1.5 + padding)
# center of selector along the other axis
center = sum(quick_min_max(magn_vals)) / 2
return bounds_init, limits, size, center