Source code for whitecanvas.canvas._base

from __future__ import annotations
from typing import Any, Callable, Iterable, Iterator, overload, TypeVar, TYPE_CHECKING
from abc import ABC, abstractmethod

from cmap import Color

import numpy as np
from numpy.typing import ArrayLike, NDArray
from psygnal import Signal, SignalGroup

from whitecanvas import protocols
from whitecanvas import layers as _l
from whitecanvas.layers import group as _lg, _mixin
from whitecanvas.types import (
    LineStyle,
    Symbol,
    ColorType,
    Alignment,
    ColormapType,
    FacePattern,
    Orientation,
    ArrayLike1D,
    Rect,
    _Void,
)
from whitecanvas.canvas import (
    _namespaces as _ns,
    layerlist as _ll,
    _categorical as _cat,
)
from whitecanvas.canvas._palette import ColorPalette
from whitecanvas.canvas._imageref import ImageRef
from whitecanvas.canvas._between import BetweenPlotter
from whitecanvas.canvas._stacked import StackPlotter
from whitecanvas.utils.normalize import as_array_1d, normalize_xy
from whitecanvas.backend import Backend, patch_dummy_backend
from whitecanvas.theme import get_theme
from whitecanvas._signal import MouseSignal, GeneratorSignal

if TYPE_CHECKING:
    from typing_extensions import Self, Concatenate, ParamSpec

    _P = ParamSpec("_P")

_L = TypeVar("_L", bound=_l.Layer)
_L0 = TypeVar("_L0", _l.Bars, _l.Band)
_void = _Void()


class CanvasEvents(SignalGroup):
    lims = Signal(Rect)
    mouse_clicked = MouseSignal(object)
    mouse_moved = GeneratorSignal()
    mouse_double_clicked = MouseSignal(object)


[docs]class CanvasBase(ABC): """Base class for any canvas object.""" title = _ns.TitleNamespace() x = _ns.XAxisNamespace() y = _ns.YAxisNamespace() layers = _ll.LayerList() events: CanvasEvents def __init__(self, palette: ColormapType | None = None): if palette is None: palette = get_theme().palette self._color_palette = ColorPalette(palette) self.events = CanvasEvents() self._is_grouping = False self._autoscale_enabled = True if not self._get_backend().name.startswith("."): self._init_canvas() def _init_canvas(self): # default colors theme = get_theme() self.x.color = theme.foreground_color self.y.color = theme.foreground_color self.x.ticks.fontfamily = theme.fontfamily self.y.ticks.fontfamily = theme.fontfamily self.x.ticks.color = theme.foreground_color self.y.ticks.color = theme.foreground_color self.x.ticks.size = theme.fontsize self.y.ticks.size = theme.fontsize self.background_color = theme.background_color # connect layer events self.layers.events.inserted.connect(self._cb_inserted, unique=True) self.layers.events.removed.connect(self._cb_removed, unique=True) self.layers.events.reordered.connect(self._cb_reordered, unique=True) canvas = self._canvas() canvas._plt_connect_xlim_changed(self._emit_xlim_changed) canvas._plt_connect_ylim_changed(self._emit_ylim_changed) def _install_mouse_events(self): canvas = self._canvas() canvas._plt_connect_mouse_click(self.events.mouse_clicked.emit) canvas._plt_connect_mouse_click(self.events.mouse_moved.emit) canvas._plt_connect_mouse_drag(self.events.mouse_moved.emit) canvas._plt_connect_mouse_double_click(self.events.mouse_double_clicked.emit) canvas._plt_connect_mouse_double_click(self.events.mouse_moved.emit) def _emit_xlim_changed(self, lim): self.x.events.lim.emit(lim) self.events.lims.emit(Rect(*lim, *self.y.lim)) def _emit_ylim_changed(self, lim): self.y.events.lim.emit(lim) self.events.lims.emit(Rect(*self.x.lim, *lim)) @abstractmethod def _get_backend(self) -> Backend: """Return the backend.""" @abstractmethod def _canvas(self) -> protocols.CanvasProtocol: """Return the canvas object.""" @property def native(self) -> Any: """Return the native canvas object.""" return self._canvas()._plt_get_native() @property def aspect_ratio(self) -> float | None: """Aspect ratio of the canvas (None if not locked).""" return self._canvas()._plt_get_aspect_ratio() @aspect_ratio.setter def aspect_ratio(self, ratio: float | None): if ratio is not None: ratio = float(ratio) self._canvas()._plt_set_aspect_ratio(ratio)
[docs] def autoscale( self, xpad: float | tuple[float, float] | None = None, ypad: float | tuple[float, float] | None = None, ): ar = np.stack([layer.bbox_hint() for layer in self.layers], axis=0) xmin = np.min(ar[:, 0]) xmax = np.max(ar[:, 1]) ymin = np.min(ar[:, 2]) ymax = np.max(ar[:, 3]) x0, x1 = self.x.lim y0, y1 = self.y.lim if np.isnan(xmin): xmin = x0 if np.isnan(xmax): xmax = x1 if np.isnan(ymin): ymin = y0 if np.isnan(ymax): ymax = y1 if xpad is not None: xrange = xmax - xmin if isinstance(xpad, (int, float, np.number)): dx0 = dx1 = xpad * xrange else: dx0, dx1 = xpad[0] * xrange, xpad[1] * xrange xmin -= dx0 xmax += dx1 if ypad is not None: yrange = ymax - ymin if isinstance(ypad, (int, float, np.number)): dy0 = dy1 = ypad * yrange else: dy0, dy1 = ypad[0] * yrange, ypad[1] * yrange ymin -= dy0 ymax += dy1 if xmax - xmin < 1e-6: xmin -= 0.05 xmax += 0.05 if ymax - ymin < 1e-6: ymin -= 0.05 ymax += 0.05 self.x.lim = xmin, xmax self.y.lim = ymin, ymax return xmin, xmax, ymin, ymax
@property def visible(self): """Show the canvas.""" return self._canvas()._plt_get_visible() @visible.setter def visible(self, visible): """Hide the canvas.""" self._canvas()._plt_set_visible(visible) @property def lims(self) -> Rect: """Return the x/y limits of the canvas.""" return Rect(*self.x.lim, *self.y.lim) @lims.setter def lims(self, lims: tuple[float, float, float, float]): xmin, xmax, ymin, ymax = lims if xmin >= xmax or ymin >= ymax: raise ValueError(f"Invalid view rect: {Rect(*lims)}") with self.events.lims.blocked(): self.x.lim = xmin, xmax self.y.lim = ymin, ymax self.events.lims.emit(Rect(xmin, xmax, ymin, ymax))
[docs] def update_axes( self, visible: bool = _void, color: ColorType | None = _void, ): if visible is not _void: self.x.ticks.visible = visible self.y.ticks.visible = visible if color is not _void: self.x.color = color self.x.ticks.color = color self.x.label.color = color self.y.color = color self.y.ticks.color = color self.y.label.color = color return self
[docs] def cat( self, data: Any, by: str | None = None, *, orient: str | Orientation = Orientation.VERTICAL, offsets: float | ArrayLike1D | None = None, palette: ColormapType | None = None, update_labels: bool = True, ) -> _cat.CategorizedDataPlotter[Self]: """ Categorize input data for plotting. This method provides categorical plotting methods for the input data. Methods are very similar to `seaborn` and `plotly.express`. >>> df = sns.load_dataset("iris") >>> canvas.cat(df, by="species").to_violinplot(y="sepal_width) >>> canvas.cat(df, by="species").mean().to_line(y="sepal_width) Parameters ---------- data : tabular data Any categorizable data. Currently, dict, pandas.DataFrame, and polars.DataFrame are supported. by : str, optional Which column to use for grouping. orient : str or Orientation, default is Orientation.VERTICAL Orientation of the plot. offsets : scalar or sequence, optional Offset for each category. If scalar, the same offset is used for all. palette : ColormapType, optional Color palette used for plotting the categories. update_labels : bool, default is True If True, update the x/y labels to the corresponding names. Returns ------- CategorizedDataPlotter Plotter object. """ orient = Orientation.parse(orient) plotter = _cat.CategorizedDataPlotter( self, data, by=by, orient=orient, offsets=offsets, update_label=update_labels, palette=palette ) # fmt: skip if update_labels: if orient.is_vertical: self.x.label.text = by else: self.y.label.text = by return plotter
[docs] def colorize( self, data: Any, by: str | None = None, *, update_labels: bool = True, palette: ColormapType | None = None, ) -> _cat.ColorizedPlotter[Self]: if palette is None: palette = self._color_palette plotter = _cat.ColorizedPlotter( self, data, by, palette=palette, update_label=update_labels ) return plotter
[docs] def stack_over(self, layer: _L0) -> StackPlotter[Self, _L0]: """ Stack new data over the existing layer. For example following code >>> bars_0 = canvas.add_bars(x, y0) >>> bars_1 = canvas.stack_over(bars_0).add(y1) >>> bars_2 = canvas.stack_over(bars_1).add(y2) will result in a bar plot like this ┌───┐ ├───│┌───┐ │ │├───│ ├───│├───│ ─┴───┴┴───┴─ """ if not isinstance(layer, (_l.Bars, _l.Band, _lg.StemPlot, _lg.LabeledBars)): raise TypeError( f"Only Bars and Band are supported as an input, " f"got {type(layer)!r}." ) return StackPlotter(self, layer)
# TODO # def annotate(self, layer, at: int): # ...
[docs] def refer_image(self, layer: _l.Image) -> ImageRef[Self]: return ImageRef(self, layer)
[docs] def between(self, l0, l1) -> BetweenPlotter[Self]: return BetweenPlotter(self, l0, l1)
@overload def add_line( self, ydata: ArrayLike1D, *, name: str | None = None, color: ColorType | None = None, width: float = 1.0, style: LineStyle | str = LineStyle.SOLID, alpha: float = 1.0, antialias: bool = True, ) -> _l.Line: # fmt: skip ... @overload def add_line( self, xdata: ArrayLike1D, ydata: ArrayLike1D, *, name: str | None = None, color: ColorType | None = None, width: float = 1.0, style: LineStyle | str = LineStyle.SOLID, alpha: float = 1.0, antialias: bool = True, ) -> _l.Line: # fmt: skip ... @overload def add_line( self, xdata: ArrayLike1D, ydata: Callable[[ArrayLike1D], ArrayLike1D], *, name: str | None = None, color: ColorType | None = None, width: float = 1.0, style: LineStyle | str = LineStyle.SOLID, alpha: float = 1.0, antialias: bool = True, ) -> _l.Line: # fmt: skip ...
[docs] def add_line( self, *args, name=None, color=None, width=1.0, style=LineStyle.SOLID, alpha=1.0, antialias=True, ): """ Add a Line layer to the canvas. >>> canvas.add_line(y, ...) >>> canvas.add_line(x, y, ...) Parameters ---------- name : str, optional Name of the layer. color : color-like, optional Color of the bars. width : float, default is 1.0 Line width. style : str or LineStyle, default is LineStyle.SOLID Line style. alpha : float, default is 1.0 Alpha channel of the line. antialias : bool, default is True Antialiasing of the line. Returns ------- Line The line layer. """ xdata, ydata = normalize_xy(*args) name = self._coerce_name(_l.Line, name) color = self._generate_colors(color) layer = _l.Line( xdata, ydata, name=name, color=color, width=width, style=style, alpha=alpha, antialias=antialias, backend=self._get_backend(), ) # fmt: skip return self.add_layer(layer)
@overload def add_markers( self, xdata: ArrayLike1D, ydata: ArrayLike1D, *, name: str | None = None, symbol: Symbol | str = Symbol.CIRCLE, size: float = 12, color: ColorType | None = None, alpha: float = 1.0, pattern: str | FacePattern = FacePattern.SOLID, ) -> _l.Markers[_mixin.ConstFace, _mixin.ConstEdge, float]: # fmt: skip ... @overload def add_markers( self, ydata: ArrayLike1D, *, name: str | None = None, symbol: Symbol | str = Symbol.CIRCLE, size: float = 12, color: ColorType | None = None, alpha: float = 1.0, pattern: str | FacePattern = FacePattern.SOLID, ) -> _l.Markers[_mixin.ConstFace, _mixin.ConstEdge, float]: # fmt: skip ...
[docs] def add_markers( self, *args, name=None, symbol=Symbol.CIRCLE, size=12, color=None, alpha=1.0, pattern=FacePattern.SOLID, ): """ Add markers (scatter plot). >>> canvas.add_markers(x, y) # standard usage >>> canvas.add_markers(y) # use 0, 1, ... for the x values Parameters ---------- name : str, optional Name of the layer. symbol : str or Symbol, default is Symbol.CIRCLE Marker symbols. size : float, default is 15 Marker size. color : color-like, optional Color of the marker faces. alpha : float, default is 1.0 Alpha channel of the marker faces. pattern : str or FacePattern, default is FacePattern.SOLID Pattern of the marker faces. Returns ------- Markers The markers layer. """ xdata, ydata = normalize_xy(*args) name = self._coerce_name(_l.Markers, name) color = self._generate_colors(color) layer = _l.Markers( xdata, ydata, name=name, symbol=symbol, size=size, color=color, alpha=alpha, pattern=pattern, backend=self._get_backend(), ) # fmt: skip return self.add_layer(layer)
@overload def add_bars( self, center: ArrayLike1D, height: ArrayLike1D, *, bottom: ArrayLike1D | None = None, name=None, orient: str | Orientation = Orientation.VERTICAL, extent: float = 0.8, color: ColorType | None = None, alpha: float = 1.0, pattern: str | FacePattern = FacePattern.SOLID, ) -> _l.Bars[_mixin.ConstFace, _mixin.ConstEdge]: # fmt: skip ... @overload def add_bars( self, height: ArrayLike1D, *, bottom: ArrayLike1D | None = None, name=None, orient: str | Orientation = Orientation.VERTICAL, extent: float = 0.8, color: ColorType | None = None, alpha: float = 1.0, pattern: str | FacePattern = FacePattern.SOLID, ) -> _l.Bars[_mixin.ConstFace, _mixin.ConstEdge]: # fmt: skip ...
[docs] def add_bars( self, *args, bottom=None, name=None, orient=Orientation.VERTICAL, extent=0.8, color=None, alpha=1.0, pattern=FacePattern.SOLID, ): """ Add a bar plot. >>> canvas.add_bars(x, heights) # standard usage >>> canvas.add_bars(heights) # use 0, 1, ... for the x values >>> canvas.add_bars(..., orient="horizontal") # horizontal bars Parameters ---------- bottom : float or array-like, optional Bottom level of the bars. name : str, optional Name of the layer. orient : str or Orientation, default is Orientation.VERTICAL Orientation of the bars. extent : float, default is 0.8 Bar width in the canvas coordinate color : color-like, optional Color of the bars. alpha : float, default is 1.0 Alpha channel of the bars. pattern : str or FacePattern, default is FacePattern.SOLID Pattern of the bar faces. Returns ------- Bars The bars layer. """ center, height = normalize_xy(*args) if bottom is not None: bottom = as_array_1d(bottom) if bottom.shape != height.shape: raise ValueError("Expected bottom to have the same shape as height") name = self._coerce_name(_l.Bars, name) color = self._generate_colors(color) layer = _l.Bars( center, height, bottom, bar_width=extent, name=name, orient=orient, color=color, alpha=alpha, pattern=pattern, backend=self._get_backend(), ) # fmt: skip return self.add_layer(layer)
[docs] def add_hist( self, data: ArrayLike1D, *, bins: int | ArrayLike1D = 10, range: tuple[float, float] | None = None, density: bool = False, name: str | None = None, color: ColorType | None = None, alpha: float = 1.0, pattern: str | FacePattern = FacePattern.SOLID, ) -> _l.Bars: name = self._coerce_name("histogram", name) color = self._generate_colors(color) layer = _l.Bars.from_histogram( data, bins=bins, range=range, density=density, name=name, color=color, alpha=alpha, pattern=pattern, backend=self._get_backend(), ) # fmt: skip return self.add_layer(layer)
[docs] def add_spans( self, spans: ArrayLike, *, name: str | None = None, orient: str | Orientation = Orientation.VERTICAL, color: ColorType = "blue", alpha: float = 0.2, pattern: str | FacePattern = FacePattern.SOLID, ) -> _l.Spans: name = self._coerce_name("histogram", name) color = self._generate_colors(color) layer = _l.Spans( spans, name=name, orient=orient, color=color, alpha=alpha, pattern=pattern, backend=self._get_backend(), ) # fmt: skip return self.add_layer(layer)
[docs] def add_infline( self, pos: tuple[float, float] = (0, 0), angle: float = 0.0, *, name: str | None = None, color: ColorType | None = None, width: float = 1.0, style: LineStyle | str = LineStyle.SOLID, antialias: bool = True, ): name = self._coerce_name(_l.InfLine, name) color = self._generate_colors(color) layer = _l.InfLine( pos, angle, name=name, color=color, width=width, style=style, antialias=antialias, backend=self._get_backend(), ) # fmt: skip return self.add_layer(layer)
[docs] def add_infcurve( self, model: Callable[Concatenate[Any, _P], Any], *, bounds: tuple[float, float] = (-np.inf, np.inf), name: str | None = None, color: ColorType | None = None, width: float = 1.0, style: str | LineStyle = LineStyle.SOLID, antialias: bool = True, ) -> _l.InfCurve[_P]: """ Add an infinite curve to the canvas. >>> canvas.add_infcurve(lambda x: x ** 2) # parabola >>> canvas.add_infcurve(lambda x, a: np.sin(a*x)).with_params(2) # parametric Parameters ---------- model : callable The model function. The first argument must be the x coordinates. Same signature as `scipy.optimize.curve_fit`. bounds : (float, float), default is (-np.inf, np.inf) Lower and upper bounds that the function is defined. name : str, optional Name of the layer. color : color-like, optional Color of the bars. width : float, default is 1.0 Line width. style : str or LineStyle, default is LineStyle.SOLID Line style. alpha : float, default is 1.0 Alpha channel of the line. antialias : bool, default is True Antialiasing of the line. Returns ------- InfCurve The infcurve layer. """ name = self._coerce_name(_l.InfCurve, name) color = self._generate_colors(color) layer = _l.InfCurve( model, bounds=bounds, name=name, color=color, width=width, style=style, antialias=antialias, backend=self._get_backend(), ) # fmt: skip return self.add_layer(layer)
[docs] def add_band( self, xdata: ArrayLike1D, ydata0: ArrayLike1D, ydata1: ArrayLike1D, *, name: str | None = None, orient: str | Orientation = Orientation.VERTICAL, color: ColorType | None = None, alpha: float = 1.0, pattern: str | FacePattern = FacePattern.SOLID, ) -> _l.Band: name = self._coerce_name(_l.Band, name) color = self._generate_colors(color) layer = _l.Band( xdata, ydata0, ydata1, name=name, orient=orient, color=color, alpha=alpha, pattern=pattern, backend=self._get_backend(), ) # fmt: skip return self.add_layer(layer)
[docs] def add_errorbars( self, xdata: ArrayLike1D, ylow: ArrayLike1D, yhigh: ArrayLike1D, *, name: str | None = None, orient: str | Orientation = Orientation.VERTICAL, color: ColorType = "blue", width: float = 1, style: LineStyle | str = LineStyle.SOLID, antialias: bool = False, capsize: float = 0.0, ) -> _l.Errorbars: name = self._coerce_name(_l.Errorbars, name) color = self._generate_colors(color) layer = _l.Errorbars( xdata, ylow, yhigh, name=name, color=color, width=width, style=style, antialias=antialias, capsize=capsize, orient=orient, backend=self._get_backend(), ) # fmt: skip return self.add_layer(layer)
[docs] def add_rug( self, events: ArrayLike1D, *, low: float = 0.0, high: float = 1.0, name: str | None = None, color: ColorType = "black", alpha: float = 1.0, orient: str | Orientation = Orientation.VERTICAL, ) -> _l.Rug: name = self._coerce_name(_l.Errorbars, name) color = self._generate_colors(color) layer = _l.Rug( events, low=low, high=high, name=name, color=color, alpha=alpha, orient=orient, backend=self._get_backend(), ) # fmt: skip return self.add_layer(layer)
[docs] def add_kde( self, data: ArrayLike1D, *, bottom: float = 0.0, name: str | None = None, orient: str | Orientation = Orientation.VERTICAL, band_width: float | str = "scott", color: ColorType | None = None, alpha: float = 1.0, pattern: str | FacePattern = FacePattern.SOLID, ): from whitecanvas.utils.kde import gaussian_kde data = as_array_1d(data) name = self._coerce_name(_l.Band, name) color = self._generate_colors(color) kde = gaussian_kde(data, bw_method=band_width) sigma = np.sqrt(kde.covariance[0, 0]) pad = sigma * 4 x = np.linspace(data.min() - pad, data.max() + pad, 100) y1 = kde(x) y0 = np.full_like(y1, bottom) layer = _l.Band( x, y0, y1, name=name, orient=orient, color=color, alpha=alpha, pattern=pattern, backend=self._get_backend(), ) # fmt: skip return self.add_layer(layer)
[docs] def add_text( self, x: ArrayLike1D, y: ArrayLike1D, string: list[str], *, color: ColorType = "black", size: float = 12, rotation: float = 0.0, anchor: str | Alignment = Alignment.BOTTOM_LEFT, fontfamily: str | None = None, ) -> _l.Texts: """ Add a text layer to the canvas. Parameters ---------- x : float or array-like X position of the text. y : float or array-like Y position of the text. string : str or list[str] Text string to display. color : ColorType, optional Color of the text string. size : float, default is 12 Point size of the text. rotation : float, default is 0.0 Rotation angle of the text in degrees. anchor : str or Alignment, default is Alignment.BOTTOM_LEFT Anchor position of the text. The anchor position will be the coordinate given by (x, y). fontfamily : str, optional Font family of the text. Returns ------- Text The text layer. """ x_, y_ = normalize_xy(x, y) if isinstance(string, str): string = [string] * x_.size else: if len(string) != x_.size: raise ValueError("Expected string to have the same size as x/y") layer = _l.Texts( x_, y_, string, color=color, size=size, rotation=rotation, anchor=anchor, fontfamily=fontfamily, backend=self._get_backend(), ) # fmt: skip return self.add_layer(layer)
[docs] def add_image( self, image: ArrayLike, *, cmap: ColormapType = "gray", clim: tuple[float | None, float | None] | None = None, flip_canvas: bool = True, lock_aspect: bool = True, ) -> _l.Image: """ Add an image layer to the canvas. This method automatically flips the image vertically by default. Parameters ---------- image : ArrayLike Image data. Must be 2D or 3D array. If 3D, the last dimension must be RGB(A). Note that the first dimension is the vertical axis. cmap : ColormapType, default is "gray" Colormap used for the image. clim : (float or None, float or None) or None Contrast limits. If None, the limits are automatically determined by min and max of the data. You can also pass None separately to either limit to use the default behavior. flip_canvas : bool, default is True If True, flip the canvas vertically so that the image looks normal. Returns ------- Image The image layer. """ layer = _l.Image(image, cmap=cmap, clim=clim, backend=self._get_backend()) self.add_layer(layer) if flip_canvas and not self.y.flipped: self.y.flipped = True if lock_aspect: self.aspect_ratio = 1.0 return layer
[docs] def add_layer( self, layer: _L, *, over: _l.Layer | Iterable[_l.Layer] | None = None, under: _l.Layer | Iterable[_l.Layer] | None = None, ) -> _L: """Add a layer to the canvas.""" if over is None and under is None: self.layers.append(layer) elif over is not None: if under is not None: raise ValueError("Cannot specify both `over` and `under`") if isinstance(over, _l.Layer): idx = self.layers.index(over) else: idx = max([self.layers.index(l) for l in over]) self.layers.insert(idx + 1, layer) else: idx = self.layers.index(under) if isinstance(under, _l.Layer): idx = self.layers.index(under) else: idx = min([self.layers.index(l) for l in under]) self.layers.insert(idx, layer) return layer
def _coerce_name(self, layer_type: type[_l.Layer] | str, name: str | None) -> str: if name is None: if isinstance(layer_type, str): name = layer_type else: name = layer_type.__name__.lower() basename = name i = 0 _exists = {layer.name for layer in self.layers} while name in _exists: name = f"{basename}-{i}" i += 1 return name def _autoscale_for_layer(self, layer: _l.Layer, pad_rel: float = 0.025): if not self._autoscale_enabled: return xmin, xmax, ymin, ymax = layer.bbox_hint() if len(self.layers) > 1: # NOTE: if there was no layer, so backend may not have xlim/ylim, # or they may be set to a default value. _xmin, _xmax = self.x.lim _ymin, _ymax = self.y.lim _dx = (_xmax - _xmin) * pad_rel _dy = (_ymax - _ymin) * pad_rel xmin = np.min([xmin, _xmin + _dx]) xmax = np.max([xmax, _xmax - _dx]) ymin = np.min([ymin, _ymin + _dy]) ymax = np.max([ymax, _ymax - _dy]) # this happens when there is <= 1 data if np.isnan(xmax) or np.isnan(xmin): xmin, xmax = self.x.lim elif xmax - xmin < 1e-6: xmin -= 0.05 xmax += 0.05 else: dx = (xmax - xmin) * pad_rel xmin -= dx xmax += dx if np.isnan(ymax) or np.isnan(ymin): ymin, ymax = self.y.lim elif ymax - ymin < 1e-6: ymin -= 0.05 ymax += 0.05 else: dy = (ymax - ymin) * pad_rel ymin -= dy # TODO: this causes bars/histogram to float ymax += dy # over the x-axis. self.lims = xmin, xmax, ymin, ymax def _cb_inserted(self, idx: int, layer: _l.Layer): if self._is_grouping: # this happens when the grouped layer is inserted layer._connect_canvas(self) return if idx < 0: idx = len(self.layers) + idx _canvas = self._canvas() for l in _iter_layers(layer): _canvas._plt_add_layer(l._backend) l._connect_canvas(self) # autoscale if isinstance(layer, _l.Image): pad_rel = 0 else: pad_rel = 0.025 self._autoscale_for_layer(layer, pad_rel=pad_rel) def _cb_removed(self, idx: int, layer: _l.Layer): if self._is_grouping: return _canvas = self._canvas() for l in _iter_layers(layer): _canvas._plt_remove_layer(l._backend) l._disconnect_canvas(self) def _cb_reordered(self): layer_backends = [] for layer in self.layers: if isinstance(layer, _l.PrimitiveLayer): layer_backends.append(layer._backend) elif isinstance(layer, _l.LayerGroup): for child in layer.iter_children_recursive(): layer_backends.append(child._backend) else: raise RuntimeError(f"type {type(layer)} not expected") self._canvas()._plt_reorder_layers(layer_backends) def _cb_layer_grouped(self, group: _l.LayerGroup): indices: list[int] = [] # layers to remove not_found: list[_l.PrimitiveLayer] = [] # primitive layers to add id_exists = set(map(id, self.layers.iter_primitives())) for layer in group.iter_children(): try: idx = self.layers.index(layer) indices.append(idx) except ValueError: not_found.extend(_iter_layers(layer)) if not indices: return self._is_grouping = True try: for idx in reversed(indices): # remove from the layer list since it is directly grouped self.layers.pop(idx) self.layers.append(group) _canvas = self._canvas() for child in not_found: if id(child) in id_exists: # skip since it is already in the canvas continue child._connect_canvas(self) _canvas._plt_add_layer(child._backend) finally: self._is_grouping = False self._cb_reordered() self._autoscale_for_layer(group) def _generate_colors(self, color: ColorType | None) -> Color: if color is None: color = self._color_palette.next() return color
[docs]class Canvas(CanvasBase): _CURRENT_INSTANCE: Canvas | None = None def __init__( self, backend: str | None = None, *, palette: ColormapType | None = None, ): self._backend = Backend(backend) self._backend_object = self._create_backend_object() super().__init__(palette=palette) self.__class__._CURRENT_INSTANCE = self
[docs] @classmethod def from_backend( cls, obj: protocols.CanvasProtocol, *, palette: ColormapType | None = None, backend: str | None = None, ) -> Self: """Create a canvas object from a backend object.""" with patch_dummy_backend() as name: self = cls(backend=name, palette=palette) self._backend = Backend(backend) self._backend_object = obj self._init_canvas() return self
def _create_backend_object(self) -> protocols.CanvasProtocol: return self._backend.get("Canvas")() def _get_backend(self): return self._backend def _canvas(self) -> protocols.CanvasProtocol: return self._backend_object
def _iter_layers( layer: _l.Layer, ) -> Iterator[_l.PrimitiveLayer[protocols.BaseProtocol]]: if isinstance(layer, _l.PrimitiveLayer): yield layer elif isinstance(layer, _l.LayerGroup): yield from layer.iter_children_recursive() else: raise TypeError(f"Unknown layer type: {type(layer).__name__}")