Source code for whitecanvas.layers._base

from __future__ import annotations

from abc import ABC, abstractmethod, abstractproperty
from typing import Generic, Iterator, TypeVar, TYPE_CHECKING
from psygnal import Signal, SignalGroup
import numpy as np
from numpy.typing import NDArray
from whitecanvas.protocols import BaseProtocol
from whitecanvas.backend import Backend

if TYPE_CHECKING:
    from whitecanvas.canvas import Canvas

_P = TypeVar("_P", bound=BaseProtocol)
_L = TypeVar("_L", bound="Layer")


class LayerEvents(SignalGroup):
    name = Signal(str)
    _layer_grouped = Signal(object)  # (group)


[docs]class Layer(ABC): events: LayerEvents _events_class = LayerEvents def __init__(self, name: str | None = None): self.events = self.__class__._events_class() self._name = name if name is not None else self.__class__.__name__ self._x_hint = self._y_hint = None @abstractproperty def visible(self) -> bool: """Return true if the layer is visible""" @visible.setter def visible(self, visible: bool): """Set the visibility of the layer""" @property def name(self) -> str: """Name of this layer.""" return self._name @name.setter def name(self, name: str): """Set the name of this layer.""" self._name = str(name)
[docs] def expect(self, layer_type: _L, /) -> _L: """ A type guard for layers. >>> canvas.layers["scatter-layer-name"].expect(Line).color """ if not isinstance(layer_type, type) or issubclass(layer_type, PrimitiveLayer): raise TypeError( "Argument of `expect` must be a layer class, " f"got {layer_type!r} (type: {type(layer_type).__name__}))" ) if not isinstance(self, layer_type): raise TypeError( f"Expected {layer_type.__name__}, got {type(self).__name__}" ) return self
def __repr__(self): return f"{self.__class__.__name__}<{self.name!r}>" def _connect_canvas(self, canvas: Canvas): """If needed, do something when layer is added to a canvas.""" self.events._layer_grouped.connect(canvas._cb_layer_grouped, unique=True) def _disconnect_canvas(self, canvas: Canvas): """If needed, do something when layer is removed from a canvas.""" self.events._layer_grouped.disconnect(canvas._cb_layer_grouped)
[docs] @abstractmethod def bbox_hint(self) -> NDArray[np.float64]: """Return the bounding box hint (xmin, xmax, ymin, ymax) of this layer."""
[docs]class PrimitiveLayer(Layer, Generic[_P]): """Layers that are composed of a single component.""" _backend: _P _backend_name: str _backend_class_name = None @property def visible(self) -> bool: """Return true if the layer is visible""" return self._backend._plt_get_visible() @visible.setter def visible(self, visible: bool): """Set the visibility of the layer""" self._backend._plt_set_visible(visible) def _create_backend(self, backend: Backend, *args) -> _P: """Create a backend object.""" if self._backend_class_name is not None: self._backend_name = backend.name return backend.get(self._backend_class_name)(*args) for mro in reversed(type(self).__mro__): name = mro.__name__ if ( issubclass(mro, PrimitiveLayer) and mro is not PrimitiveLayer and backend.has(name) ): self._backend_name = backend.name return backend.get(name)(*args) raise TypeError( f"Cannot create a {backend.name} backend for {type(self).__name__}" )
[docs] def bbox_hint(self) -> NDArray[np.float64]: """Return the bounding box hint (xmin, xmax, ymin, ymax) of this layer.""" if self._x_hint is None: _x = (np.nan, np.nan) else: _x = self._x_hint if self._y_hint is None: _y = (np.nan, np.nan) else: _y = self._y_hint return np.array(_x + _y, dtype=np.float64)
[docs]class LayerGroup(Layer): """ A group of layers that will be treated as a single layer in the canvas. """ def __init__(self, name: str | None = None): super().__init__(name) self._visible = True
[docs] @abstractmethod def iter_children(self) -> Iterator[Layer]: """Iterate over all children."""
[docs] def iter_children_recursive(self) -> Iterator[PrimitiveLayer[BaseProtocol]]: for child in self.iter_children(): if isinstance(child, LayerGroup): yield from child.iter_children_recursive() else: yield child
def _emit_layer_grouped(self): """Emit all the grouped signal.""" for c in self.iter_children(): c.events._layer_grouped.emit(self) @property def visible(self) -> bool: """Return true if the layer is visible""" return self._visible @visible.setter def visible(self, visible: bool): """Set the visibility of the layer""" self._visible = visible for child in self.iter_children(): child.visible = visible @property def _backend_name(self) -> str: """The backend name of this layer group.""" for child in self.iter_children(): return child._backend_name raise RuntimeError(f"No backend name found for {self!r}")
[docs] def bbox_hint(self) -> NDArray[np.float64]: """ Return the bounding box hint (xmin, xmax, ymin, ymax) of this group. Note that unless children notifies the change of their bounding box hint, bbox hint needs recalculation. """ hints = [child.bbox_hint() for child in self.iter_children()] if len(hints) == 0: return np.array([np.nan, np.nan, np.nan, np.nan], dtype=np.float64) ar = np.stack(hints, axis=1) # Any of the four corners could be all-nan. In that case, we should return nan. # Otherwise, we should return the known min/max. allnan = np.isnan(ar).all(axis=1) xmin = np.nan if allnan[0] else np.nanmin(ar[0, :]) xmax = np.nan if allnan[1] else np.nanmax(ar[1, :]) ymin = np.nan if allnan[2] else np.nanmin(ar[2, :]) ymax = np.nan if allnan[3] else np.nanmax(ar[3, :]) return np.array([xmin, xmax, ymin, ymax], dtype=np.float64)