import itertools
import cmap as cmap_lib
import numpy as np
from .line import LineGraphic
from .scatter import ScatterGraphic
from .image import ImageGraphic
from ._collection_base import GraphicCollection, cmap_across_graphics
from .selectors import (
LinearSelector,
LinearRegionSelector,
RectangleSelector,
PolygonSelector,
)
from ..utils import calculate_figure_shape
class PositionsCollection(GraphicCollection):
"""A collection of positions-based graphics (lines, scatters); adds selectors spanning all graphics."""
# appended as a Notes section to the generated constructor stub (scripts/generate_graphics_stubs.py),
# since the child graphic's docstring describes `cmap`/`cmap_transform` for a single graphic only
_stub_constructor_notes = """\
Notes
-----
``cmap`` and ``cmap_transform`` apply across the collection. A single ``cmap`` gives each graphic
one color spread across the colormap, selected by a 1D ``cmap_transform`` (one value per graphic).
An iterable of colormaps gives each graphic its own colormap along its datapoints, with a
per-graphic ``cmap_transform``."""
def __init__(
self, data, *, cmap=None, cmap_transform=None, cmap_range=None, **kwargs
):
super().__init__(data, **kwargs)
self._set_cmap(cmap, cmap_transform, cmap_range)
def _set_cmap(self, cmap, cmap_transform=None, cmap_range=None):
"""
A single cmap (str or ``cmap_lib.Colormap``) gives each graphic a uniform color.
An iterable of cmaps gives each graphic its own colormap.
"""
if hasattr(cmap, "__next__"):
# an iterator (itertools.repeat/cycle, a generator, ...): one cmap per graphic,
# materialized so re-applying it (e.g. each frame in NDPositions) stays stable
cmap = list(itertools.islice(cmap, len(self)))
self._cmap = cmap
self._cmap_transform = cmap_transform
self._cmap_range = cmap_range
if cmap is None:
if cmap_transform is not None:
raise ValueError("must pass `cmap` if passing `cmap_transform`")
return
single_cmap = isinstance(cmap, (str, cmap_lib.Colormap))
# a single cmap needs a 1D transform (across graphics), an iterable needs a 2D transform (per-graphic)
if cmap_transform is not None and single_cmap == (
np.ndim(cmap_transform[0]) >= 1
):
raise ValueError(
"`cmap` and `cmap_transform` must match: a single `cmap` uses a 1D transform, "
"an iterable of cmaps uses a 2D transform"
)
if single_cmap:
self.colors[:] = cmap_across_graphics(
cmap, len(self), cmap_transform, cmap_range
)
return
if len(cmap) != len(self):
raise IndexError(
f"len(cmap) must equal the number of graphics, got {len(cmap)} cmaps for "
f"{len(self)} graphics"
)
if cmap_transform is not None and len(cmap_transform) != len(self):
raise IndexError(
f"len(cmap_transform) must equal the number of graphics, got "
f"{len(cmap_transform)} `cmap_transform` values for {len(self)} graphics"
)
if np.ndim(cmap_range) == 2 and len(cmap_range) != len(self):
raise IndexError(
f"len(cmap_range) must equal the number of graphics, got {len(cmap_range)} "
f"`cmap_range` values for {len(self)} graphics"
)
transforms = (
cmap_transform if cmap_transform is not None else itertools.repeat(None)
)
ranges = (
cmap_range if np.ndim(cmap_range) == 2 else itertools.repeat(cmap_range)
)
for graphic, one_cmap, transform, rng in zip(
self.graphics, cmap, transforms, ranges
):
graphic.cmap = one_cmap
if transform is not None:
graphic.cmap_transform = transform
if rng is not None:
graphic.cmap_range = rng
@property
def cmap(self) -> str | list | None:
"""
get or set the colormap(s) across the collection
A single colormap gives each graphic one color, spread across the colormap. An iterable of
colormaps gives each graphic its own colormap along its datapoints.
"""
return self._cmap
@cmap.setter
def cmap(self, value):
self._set_cmap(value, self._cmap_transform, self._cmap_range)
@property
def cmap_transform(self) -> np.ndarray | None:
"""
get or set the cmap_transform across the collection
With a single ``cmap`` the transform is 1D, one value per graphic, selecting each graphic's
color. With an iterable of colormaps the transform is per-graphic, coloring each graphic
along its datapoints.
"""
return self._cmap_transform
@cmap_transform.setter
def cmap_transform(self, value):
self._set_cmap(self._cmap, value, self._cmap_range)
@property
def cmap_range(self) -> tuple[float, float] | None:
"""get or set the cmap_range of the graphics in the collection"""
return self._cmap_range
@cmap_range.setter
def cmap_range(self, value):
self._set_cmap(self._cmap, self._cmap_transform, value)
def add_linear_selector(
self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs
) -> LinearSelector:
"""
Add a :class:`.LinearSelector`.
Parameters
----------
selection: float, optional
initial position of the selector along ``axis``, computed from the data if not given
padding: float, default 0.0
extra padding along the orthogonal axis to make the selector easier to grab
axis: str, default "x"
axis the selector moves along
**kwargs
passed to :class:`.LinearSelector`
Returns
-------
LinearSelector
"""
bounds_init, limits, size, center = self._get_linear_selector_init_args(
axis, padding
)
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
def add_linear_region_selector(
self,
selection: tuple[float, float] = None,
padding: float = 0.0,
axis: str = "x",
**kwargs,
) -> LinearRegionSelector:
"""
Add a :class:`.LinearRegionSelector`.
Parameters
----------
selection: (float, float), optional
initial bounds of the region along ``axis``, computed from the data if not given
padding: float, default 0.0
extra padding along the orthogonal axis to make the selector easier to grab
axis: str, default "x"
axis the selector spans
**kwargs
passed to :class:`.LinearRegionSelector`
Returns
-------
LinearRegionSelector
"""
bounds_init, limits, size, center = self._get_linear_selector_init_args(
axis, padding
)
if selection is None:
selection = bounds_init
selector = LinearRegionSelector(
selection=selection,
limits=limits,
size=size,
center=center,
axis=axis,
parent=self,
**kwargs,
)
self._plot_area.add_graphic(selector, center=False)
return selector
def add_rectangle_selector(
self, selection: tuple[float, float, float, float] = None, **kwargs
) -> RectangleSelector:
"""
Add a :class:`.RectangleSelector`.
Parameters
----------
selection: (float, float, float, float), optional
initial (xmin, xmax, ymin, ymax), computed from the data if not given
**kwargs
passed to :class:`.RectangleSelector`
Returns
-------
RectangleSelector
"""
bbox = self.world_object.get_world_bounding_box()
xdata = np.concatenate(self.data[:, :, 0])
xmin, xmax = np.nanmin(xdata), np.nanmax(xdata)
# y from the world bounding box so that the graphics' offsets, e.g. a stack's, are included
ymin, ymax = bbox[0, 1], bbox[1, 1]
yspan = ymax - ymin
if selection is None:
# the first quarter along x, the full y extent
selection = (xmin, xmin + (xmax - xmin) / 4, ymin, ymax)
limits = (xmin, xmax, ymin - yspan / 2, ymax + yspan / 2)
selector = RectangleSelector(
selection=selection, limits=limits, parent=self, **kwargs
)
self._plot_area.add_graphic(selector, center=False)
return selector
def add_polygon_selector(
self, selection: list[tuple[float, float]] = None, **kwargs
) -> PolygonSelector:
"""
Add a :class:`.PolygonSelector`.
Parameters
----------
selection: list of (float, float), optional
initial polygon points; if not given, you draw the polygon by clicking
**kwargs
passed to :class:`.PolygonSelector`
Returns
-------
PolygonSelector
"""
bbox = self.world_object.get_world_bounding_box()
xdata = np.concatenate(self.data[:, :, 0])
xmin, xmax = np.nanmin(xdata), np.nanmax(xdata)
# y from the world bounding box so that the graphics' offsets, e.g. a stack's, are included
ymin, ymax = bbox[0, 1], bbox[1, 1]
yspan = ymax - ymin
limits = (xmin, xmax, ymin - yspan / 2, ymax + yspan / 2)
selector = PolygonSelector(selection, limits, parent=self, **kwargs)
self._plot_area.add_graphic(selector, center=False)
return selector
def _get_linear_selector_init_args(self, axis: str, padding: float):
bbox = self.world_object.get_world_bounding_box()
axis_index = {"x": 0, "y": 1}[axis]
orthogonal_index = 1 - axis_index
data = np.concatenate(self.data[:, :, axis_index])
vmin, vmax = np.nanmin(data), np.nanmax(data)
# the first quarter along `axis`
bounds = (vmin, vmin + (vmax - vmin) / 4)
limits = (vmin, vmax)
# size and center on the orthogonal axis, from the world bounding box so that the
# graphics' offsets, e.g. a stack's, are included
size = np.ptp(bbox[:, orthogonal_index]) * 1.5 + padding
center = bbox[:, orthogonal_index].mean()
return bounds, limits, size, center
[docs]
class LineCollection(PositionsCollection):
_child_type = LineGraphic
[docs]
class ScatterCollection(PositionsCollection):
_child_type = ScatterGraphic
[docs]
class ImageCollection(GraphicCollection):
_child_type = ImageGraphic
[docs]
class ImageGrid(ImageCollection):
def __init__(
self,
data,
*,
shape: tuple[int, int] = None,
separation: tuple[float, float] = (0.0, 0.0),
offsets: np.ndarray = None,
**kwargs,
):
"""
Lay out a collection of images in a grid.
If ``offsets`` is given it is used directly as the per-image position. Otherwise the images
are placed row-major into a grid of ``shape`` (rows, columns), each cell sized to the
largest image so the rows and columns line up, with ``separation`` world-space gaps between
them. By default there is separation space between the images.
Parameters
----------
data: list of array-like
one image per grid cell
shape: (int, int), optional
grid (n_rows, n_cols); defaults to a roughly square grid that fits all the images
separation: (float, float), default (0.0, 0.0)
world-space (row, column) gaps between the images
offsets: array-like, optional
explicit (x, y, z) offset per image; when given, ``shape`` and ``separation`` are ignored
**kwargs
passed to :class:`.ImageCollection`, e.g. ``cmap``, ``vmin``, ``vmax``
"""
super().__init__(data, **kwargs)
n = len(self)
if offsets is None:
if shape is None:
shape = calculate_figure_shape(n) # roughly square (rows, cols)
if np.prod(shape) < n:
raise ValueError(
f"grid shape {shape} has fewer cells than the {n} images"
)
rows, cols = np.divmod(np.arange(n), shape[1])
# cell size = the largest image, via the data accessor, so rows and columns line up
sizes = np.array(
[image.shape[:2] for image in self.data[:]]
) # (rows, cols) per image
cell_height, cell_width = sizes.max(axis=0)
row_sep, col_sep = separation
offsets = np.zeros((n, 3))
offsets[:, 0] = cols * (cell_width + col_sep) # x, left to right
offsets[:, 1] = -rows * (cell_height + row_sep) # y, top row first
self.offsets[:] = offsets
class GraphicStack:
"""
Mixin that stacks a collection's graphics along the axes in ``separation_axis``. Each graphic is
offset by its index times the data max plus the ``separation`` gap, so the graphics are evenly
spaced and do not overlap; pass per-graphic ``steps`` to space them individually. Set
``separation`` or ``separation_axis`` to (re)stack, e.g. after changing the data.
"""
def __init__(
self,
data,
*,
separation: tuple[float, float, float] = (0.0, 0.0, 0.0),
separation_axis: str = "y",
steps: np.ndarray = None,
**kwargs,
):
"""
Create a stack of graphics.
Parameters
----------
data: list of array-like
one entry per graphic; its length is the number of graphics in the stack
separation: (float, float, float), default (0.0, 0.0, 0.0)
(x, y, z) gap between successive graphics, added to the step along the corresponding
stacking axis
separation_axis: str, default "y"
axes to stack along, any combination of "x", "y", "z", e.g. "y", "xy", "xyz"
steps: [n_graphics, 3] array-like, optional
per-graphic step along each (x, y, z) axis, i.e. the max each graphic reaches. When
``None`` (default) a single max over all the data sets one uniform step. When given, each
graphic is offset by the cumulative step of the graphics before it, plus ``separation``.
**kwargs
passed to the collection, e.g. ``colors``, ``thickness``, ``sizes``
"""
super().__init__(data, **kwargs)
self._separation = self._check_separation(separation)
self._steps = self._check_steps(steps)
self.separation_axis = separation_axis # (re)stacks
def _check_separation(self, separation) -> np.ndarray:
separation = np.asarray(separation, dtype=float)
if separation.shape != (3,):
raise ValueError(
f"separation must be an (x, y, z) iterable, got shape {separation.shape}"
)
return separation
def _check_steps(self, steps) -> np.ndarray | None:
if steps is None:
return None
steps = np.asarray(steps, dtype=float)
if steps.shape != (len(self), 3):
raise ValueError(
f"steps must be a [n_graphics, 3] array, got shape {steps.shape} for "
f"{len(self)} graphics"
)
return steps
@property
def separation(self) -> np.ndarray:
"""get or set the (x, y, z) gap added to the step along the stacking axes"""
return self._separation
@separation.setter
def separation(self, value: tuple[float, float, float]):
self._separation = self._check_separation(value)
self._restack()
@property
def steps(self) -> np.ndarray | None:
"""get or set the per-graphic (x, y, z) steps used to space the stack, ``None`` to auto-determine"""
return self._steps
@steps.setter
def steps(self, value: np.ndarray | None):
self._steps = self._check_steps(value)
self._restack()
@property
def separation_axis(self) -> str:
"""get or set the axes to stack along, e.g. "y", "xy", "xyz\" """
return self._separation_axis
@separation_axis.setter
def separation_axis(self, value: str):
if not set(value).issubset("xyz"):
raise ValueError(
f"separation_axis must be a combination of 'x', 'y', 'z', got {value!r}"
)
self._separation_axis = value
self._restack()
def _restack(self):
axes = [{"x": 0, "y": 1, "z": 2}[axis] for axis in self._separation_axis]
offsets = np.zeros((len(self), 3))
if self._steps is None:
# one max over all the data gives the step to stack by along each stacking axis,
# reduce each graphic first so the whole dataset is never concatenated
step = np.max([view.max(axis=0) for view in self.data[:, :, axes]], axis=0)
offsets[:, axes] = np.arange(len(self))[:, np.newaxis] * (
step + self._separation[axes]
)
else:
# per-graphic steps: offset each graphic past the previous ones by their cumulative step
offsets[1:, axes] = np.cumsum(
self._steps[:-1, axes] + self._separation[axes], axis=0
)
self.offsets[:] = offsets
[docs]
class LineStack(GraphicStack, LineCollection):
pass
[docs]
class ScatterStack(GraphicStack, ScatterCollection):
pass