from __future__ import annotations
import inspect
from collections.abc import Iterable
from typing import Any
import numpy as np
import pygfx
import cmap as cmap_lib
from ._base import Graphic
from ._jagged_array import (
CollectionFeatureAccessor,
JaggedCollectionFeature,
CollectionColors,
CollectionCmap,
ARRAY_BUFFER_FEATURES,
)
from .features import GraphicFeature, VertexColors, UniformColor, VertexCmap
# a feature the collection also owns as a `Graphic` is exposed under a plural name, so
# `collection.offset` is the collection's own offset and `collection.offsets` is the per-graphic
# offsets
PLURAL = {
"name": "names",
"offset": "offsets",
"rotation": "rotations",
"scale": "scales",
"alpha": "alphas",
"alpha_mode": "alpha_modes",
"visible": "visibles",
"metadata": "metadatas",
}
# features not exposed across the collection
EXCLUDE = {"deleted"}
def get_accessor_class(feature: str, feature_classes: tuple[type, ...]) -> type:
"""the accessor class used to manage a feature across a collection"""
if UniformColor in feature_classes or VertexColors in feature_classes:
return CollectionColors
if VertexCmap in feature_classes:
return CollectionCmap
if any(
issubclass(c, ARRAY_BUFFER_FEATURES)
for c in feature_classes
if isinstance(c, type)
):
return JaggedCollectionFeature
if feature in ("offset", "rotation", "scale"):
return JaggedCollectionFeature
return CollectionFeatureAccessor
def get_value_ndim(feature_classes: tuple[type[GraphicFeature], ...]) -> int:
"""number of dimensions of a value that applies to every graphic, i.e. the uniform variant"""
return min((c.ndim for c in feature_classes if isinstance(c, type)), default=0)
def cmap_across_graphics(
cmap_name: str,
n_graphics: int,
transform: np.ndarray = None,
cmap_range: tuple[float, float] = None,
) -> np.ndarray:
"""
``n_graphics`` colors from a colormap, one per graphic.
Without a transform the colors are evenly spaced along the colormap. A ``transform`` maps each
graphic into the colormap instead. A qualitative colormap indexes its colors with the transform
values directly, so a given value always gets the same color, e.g. cluster labels. Any other
colormap resamples the transform to one value per graphic and normalizes it over ``cmap_range``,
or over the transform's own (min, max) if no range is given.
"""
cmap = cmap_lib.Colormap(cmap_name)
if transform is None:
if cmap_range is not None:
raise ValueError("must pass `cmap_transform` if passing `cmap_range`")
return np.asarray(cmap(np.linspace(0, 1, n_graphics)))
transform = np.asarray(transform)
if cmap.interpolation == "nearest":
# qualitative, the transform values are indices into the colormap's colors
if not np.issubdtype(transform.dtype, np.integer):
raise TypeError(
f"a qualitative colormap requires an integer `cmap_transform`, got dtype: "
f"{transform.dtype}"
)
if len(transform) != n_graphics:
raise IndexError(
f"len(cmap_transform) must equal the number of graphics, got {len(transform)} "
f"`cmap_transform` values for {n_graphics} graphics"
)
if transform.min() < 0 or transform.max() >= cmap.num_colors:
raise IndexError(
f"`cmap_transform` values must be integers within the range of the number of "
f"colors in the provided colormap, `{cmap.name}` has {cmap.num_colors} colors, "
f"got range: [{transform.min()}, {transform.max()}]"
)
if cmap_range is not None:
raise ValueError(
f"`cmap_range` must be `None` for a qualitative colormap, got: {cmap_range!r}"
)
return np.asarray(cmap(transform / max(cmap.num_colors - 1, 1)))
transform = transform.astype(float)
# resample the transform to one value per graphic
transform = np.interp(
np.linspace(0, 1, n_graphics), np.linspace(0, 1, len(transform)), transform
)
# normalize over the range so the values index the colormap
vmin, vmax = (
cmap_range if cmap_range is not None else (transform.min(), transform.max())
)
spread = vmax - vmin
values = (transform - vmin) / spread if spread else np.zeros(n_graphics)
return np.asarray(cmap(values))
class _AccessorProperty(property):
"""marks a property as generated for a collection feature, so a subclass's own explicit
property for a feature can be told apart from a generated one"""
def make_feature_property(feature_name: str, accessor_class: type) -> property:
"""a property that returns the feature's accessor for get/set across the collection"""
def getter(collection_instance):
return getattr(collection_instance, f"_{feature_name}")
if accessor_class is CollectionCmap:
# assigning a colormap colors each graphic one color, evenly spaced across the collection
def setter(collection_instance, value):
collection_instance.colors[:] = cmap_across_graphics(
value, len(collection_instance)
)
else:
def setter(collection_instance, value):
getattr(collection_instance, f"_{feature_name}")[:] = value
doc = f"get or set the {feature_name} of the graphics in the collection"
return _AccessorProperty(getter, setter, doc=doc)
def make_collection_signature(cls: type) -> inspect.Signature:
"""
the collection constructor's signature
``data`` becomes the list of per-graphic data and each managed feature accepts one value for all
graphics or one per graphic (``Iterable``), both derived from the child graphic. The parameters
the collection itself takes are added as-is: its own ``Graphic`` parameters, and any parameter a
collection subclass declares, e.g. a stack's ``separation``.
"""
parameters = dict()
for name, parameter in inspect.signature(
cls._child_type.__init__
).parameters.items():
if name in ("self", "data") or parameter.kind in (
parameter.VAR_POSITIONAL,
parameter.VAR_KEYWORD,
):
continue
feature_name = PLURAL.get(name, name)
annotation = parameter.annotation
if feature_name in cls._accessor_specs and annotation is not parameter.empty:
annotation = Iterable[annotation]
default = (
parameter.default if parameter.default is not parameter.empty else None
)
parameters[feature_name] = inspect.Parameter(
feature_name,
inspect.Parameter.KEYWORD_ONLY,
default=default,
annotation=annotation,
)
# features the collection exposes but the child takes via **kwargs, e.g. names, offsets, metadatas
for feature_name in cls._accessor_specs:
if feature_name == "data" or feature_name in parameters:
continue
parameters[feature_name] = inspect.Parameter(
feature_name, inspect.Parameter.KEYWORD_ONLY, default=None
)
# the collection's own parameters, from `Graphic` and from each collection subclass `__init__`
for klass in reversed(cls.__mro__):
for name, parameter in inspect.signature(klass.__init__).parameters.items():
if name in ("self", "data") or name in parameters:
continue
if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD):
continue
parameters[name] = parameter.replace(kind=inspect.Parameter.KEYWORD_ONLY)
return inspect.Signature(
[
inspect.Parameter("data", inspect.Parameter.POSITIONAL_OR_KEYWORD),
*parameters.values(),
]
)
[docs]
class GraphicCollection(Graphic):
"""
A collection of graphics of the same type.
Subclasses set only ``_child_type``. Each feature of the child graphic is then exposed as a
property returning an accessor that gets and sets that feature across all of the graphics using
numpy broadcasting, e.g. ``collection.colors[:10, 30:50] = "r"``. Features the collection also
owns as a ``Graphic`` (``name``, ``offset``, ``rotation``, ``scale``, ``alpha``, ``alpha_mode``,
``visible``, ``metadata``) are exposed under a plural name (``names``, ``offsets``, ...). The
constructor signature is derived from the child graphic as well.
"""
_child_type: type[Graphic] = None
# tooltips come from the child graphics
_fpl_support_tooltip = False
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if cls._child_type is None:
return
# exposed feature name -> (child feature, accessor class, value_ndim)
specs = dict()
for feature, feature_classes in cls._child_type._features.items():
if feature in EXCLUDE:
continue
feature_classes = (
feature_classes
if isinstance(feature_classes, tuple)
else (feature_classes,)
)
feature_name = PLURAL.get(feature, feature)
existing = getattr(cls, feature_name, None)
if isinstance(existing, property) and not isinstance(
existing, _AccessorProperty
):
continue # the subclass implements this feature with its own property; no accessor
specs[feature_name] = (
feature,
get_accessor_class(feature, feature_classes),
get_value_ndim(feature_classes),
)
# metadata is a plain attribute, not a graphic feature, so add it explicitly
specs["metadatas"] = ("metadata", CollectionFeatureAccessor, 0)
cls._accessor_specs = specs
# install a property for each feature, unless the class already defines one
for feature_name, (feature, accessor_class, _) in cls._accessor_specs.items():
if isinstance(getattr(cls, feature_name, None), property):
continue
setattr(
cls, feature_name, make_feature_property(feature_name, accessor_class)
)
# expose the feature names so `add_event_handler` routes feature events to the accessor
cls._features = {
**cls._features,
**{name: spec[1] for name, spec in cls._accessor_specs.items()},
}
cls.__signature__ = make_collection_signature(cls)
def __init__(self, data, **kwargs):
"""
Create a collection of graphics of the same type.
Parameters
----------
data: list of array-like
one entry per graphic; its length is the number of graphics in the collection
**kwargs
any feature of the child graphic (``colors``, ``thickness``, ``sizes``, ...), each
accepting one value for all graphics or one value per graphic. A ``Graphic`` argument
(``name``, ``offset``, ``visible``, ...) sets it on the collection itself, its plural
form (``names``, ``offsets``, ``visibles``, ...) sets it per graphic. Any argument that
is not a feature is passed unchanged to every child graphic.
"""
# the singular name sets the collection's own value, the plural form sets it per graphic
super().__init__(
**{name: kwargs.pop(name) for name in PLURAL.keys() & kwargs.keys()}
)
n_graphics = len(data)
if n_graphics == 0:
raise ValueError(
"a collection needs at least one graphic, got an empty `data`"
)
self._graphics = np.empty(n_graphics, dtype=object)
self._set_world_object(pygfx.Group())
self._create_accessors(data_value_ndim=int(np.ndim(data[0])))
feature_values = dict() # child feature -> iterator of one value per graphic
graphic_kwargs = dict() # non-feature kwargs, same for every graphic
# split each feature into one value per graphic, other kwargs go to every graphic
for feature_name, value in kwargs.items():
if feature_name not in self._accessor_specs:
graphic_kwargs[feature_name] = value
continue
feature = self._accessor_specs[feature_name][0]
accessor = getattr(self, f"_{feature_name}")
value = accessor._parse_feature_value(value, ())
feature_values[feature] = iter(
accessor._broadcast_over_graphics(value, n_graphics)
)
# one graphic per data entry, filled into the preallocated array
for i, graphic_data in enumerate(data):
feature_kwargs = {
feature: next(values) for feature, values in feature_values.items()
}
graphic = self._child_type(graphic_data, **feature_kwargs, **graphic_kwargs)
self._check_graphic_features_modes(graphic)
self._graphics[i] = graphic
self.world_object.add(graphic.world_object)
def _create_accessors(self, data_value_ndim: int):
# one accessor per exposed feature, over the collection's graphics array
for feature_name, (
feature,
accessor_class,
value_ndim,
) in self._accessor_specs.items():
setattr(
self,
f"_{feature_name}",
accessor_class(
self._graphics, feature, value_ndim, feature_name=feature_name
),
)
# data is the loop driver, so its value_ndim comes from the data, not a feature class
self._data._value_ndim = data_value_ndim
@property
def graphics(self) -> np.ndarray[Graphic]:
"""the graphics in the collection"""
graphics = self._graphics.view()
graphics.flags.writeable = False
return graphics
[docs]
def add_graphic(self, graphic: Graphic):
"""
Add a graphic to the collection.
Parameters
----------
graphic: Graphic
the graphic to add; must be of the collection's ``_child_type`` and match the
per-vertex or uniform buffer mode of the graphics already in the collection
"""
if not isinstance(graphic, self._child_type):
raise TypeError(
f"cannot add a `{type(graphic).__name__}` to a collection of `{self._child_type.__name__}`"
)
self._check_graphic_features_modes(graphic)
graphics = np.empty(self._graphics.size + 1, dtype=object)
graphics[:-1] = self._graphics
graphics[-1] = graphic
self._graphics = graphics
self._refresh_accessors()
# a collection already in a plot area passes it on, like `_fpl_add_plot_area_hook` does
if self._plot_area is not None:
graphic._fpl_add_plot_area_hook(self._plot_area)
self.world_object.add(graphic.world_object)
[docs]
def remove_graphic(self, graphic: Graphic):
"""
Remove a graphic from the collection.
Parameters
----------
graphic: Graphic
the graphic to remove
"""
index = next((i for i, g in enumerate(self._graphics) if g is graphic), None)
if index is None:
raise KeyError("graphic is not in the collection")
self._graphics = np.delete(self._graphics, index)
self._refresh_accessors()
self.world_object.remove(graphic.world_object)
def _check_graphic_features_modes(self, graphic: Graphic):
# every graphic must use the same feature types (per-vertex vs uniform) as the first one,
# so the accessors can index them all the same way
if self._graphics.size == 0 or self._graphics[0] is None:
return
reference = self._graphics[0]
for feature, _, _ in self._accessor_specs.values():
reference_feature = getattr(reference, f"_{feature}", None)
if not isinstance(reference_feature, GraphicFeature):
continue # e.g. metadata, not a graphic feature
if not isinstance(
getattr(graphic, f"_{feature}", None), type(reference_feature)
):
raise TypeError(
f"graphics in a collection must use the same `{feature}` type; the collection "
f"uses `{type(reference_feature).__name__}`"
)
def _refresh_accessors(self):
# point each accessor at the current graphics array
for feature_name in self._accessor_specs:
getattr(self, f"_{feature_name}")._graphics = self._graphics
@property
def imgui_right_click(self) -> tuple:
"""
The imgui popup of each graphic of this collection, in order.
A right-click picks the graphic under the pointer, never the collection, so each graphic has
its own popup. Passing a function to :meth:`set_imgui_right_click` wraps it in a separate
popup per graphic, passing an ``ImguiPopup`` shares that one instance between them.
"""
return tuple(graphic.imgui_right_click for graphic in self._graphics)
[docs]
def set_imgui_right_click(self, popup=None, *, window_flags=None):
"""
Set the imgui popup opened by a right-click on any graphic of this collection.
A right-click picks the graphic under the pointer, never the collection, so the popup is set
on each graphic. Takes the same arguments as :meth:`Graphic.set_imgui_right_click`.
"""
def decorator(_popup):
for graphic in self._graphics:
graphic.set_imgui_right_click(_popup, window_flags=window_flags)
return _popup
if popup is None:
return decorator
decorator(popup)
[docs]
def append_imgui_right_click(self, gui=None):
"""
Append imgui elements to the popup of every graphic of this collection.
Takes the same arguments as :meth:`Graphic.append_imgui_right_click`.
"""
def decorator(_gui):
for graphic in self._graphics:
graphic.append_imgui_right_click(_gui)
return _gui
if gui is None:
return decorator
decorator(gui)
[docs]
def remove_imgui_right_click(self, popup):
"""
Remove ``popup`` from every graphic of this collection that has it set.
Unlike :meth:`Graphic.remove_imgui_right_click` this takes the popup to remove, since the
graphics of a collection do not necessarily share one.
Parameters
----------
popup: ImguiPopup
the popup to remove, one of those returned by :attr:`imgui_right_click`
"""
for graphic in self._graphics:
if graphic.imgui_right_click is popup:
graphic.remove_imgui_right_click()
def _fpl_add_plot_area_hook(self, plot_area):
super()._fpl_add_plot_area_hook(plot_area)
for graphic in self._graphics:
graphic._fpl_add_plot_area_hook(plot_area)
def _fpl_prepare_del(self):
# the base clears this world object's and its children's handlers, so it runs first
super()._fpl_prepare_del()
self.world_object.clear()
for graphic in self._graphics:
graphic._fpl_prepare_del()
def __len__(self) -> int:
return self._graphics.size
def __iter__(self):
return iter(self._graphics)
def __contains__(self, graphic: Graphic) -> bool:
return graphic in self._graphics
def __repr__(self) -> str:
return f"{type(self).__name__} of <{len(self)}> {self._child_type.__name__}"