"""
Implements the :class:`Plot` class which visualizes a single plot within a
:class:`Figure`.
"""
from __future__ import annotations
from typing import Optional, TYPE_CHECKING
import matplotlib.pyplot as plt
import numpy as np
from scistag.imagestag.bounding import Bounding2D
from scistag.imagestag.size2d import Size2DTypes
from scistag.imagestag.font import Font, VTextAlignment
from scistag.imagestag.font_registry import FontRegistry
from scistag.plotstag.layers.image_layer import ImageLayer
if TYPE_CHECKING:
from scistag.plotstag.figure import Figure
from scistag.imagestag import Size2D, Pos2D, Canvas, Colors, Image, Color
from scistag.plotstag.plot_layer import PlotLayer
[docs]class Plot:
"""
Defines a single plot.
A plot defines an area within a :class:`Figure` which can consist of
one or multiple plots overlaying of each other using the
:class:`PlotLayer`s provided such as line charts, scatter plots, images
etc.
"""
def __init__(self, target_size: Size2DTypes | None = None):
"""
:param target_size: The target size in pixels
"""
self.target_size = Size2D(target_size) if target_size is not None \
else None
"Desired target size in pixels"
self.size: Size2D = target_size
"Plot's effective size in pixels (as far as defined)"
self.layers: list[PlotLayer] = []
"Single data visualization layers (the real plots)"
self.border_width = 2.0
"Border width in pixels"
self.border_margins = [self.border_width] * 4
"Border margin in pixels"
self.border_color = Colors.BLACK
"Border color"
self.margins = [margin for margin in self.border_margins]
"""
Margin around the main layers in pixels on each side
(left, top, right, bottom)
"""
self.layer_pos = Pos2D(self.margins[0], self.margins[1])
"Position at which the layers shall be plotted"
self.layer_size = Size2D(0, 0)
"The layer size in pixels"
self._figure: Optional["Figure"] = None
"Figure which contains this plot"
self.column = 0
"Column in which the plot is displayed. Auto-assigned by the figure"
self.row = 0
"Row in which the plot is displayed. Auto-assigned by the figure"
self.index = 0
"Enumeration index of the plot. Auto-assigned by the figure"
self.title = None
"The plot's title"
self.title_height = 0.0
"The title's size in pixels"
self._title_font: Font | None = None
"Cached title font"
self.title_spacing: tuple[float, float] = (0.0, 0.0)
"Space above and below the title"
self.title_color = Colors.BLACK
"The title's color"
[docs] def _repr_png_(self) -> bytes:
"""
Creates a PNG representation for Jupyter
:return: The PNG data
"""
return self.render().to_png()
[docs] def add_layer(self, layer: PlotLayer):
"""
Adds a layer to this plot
:param layer: The new layer
"""
self.layers.append(layer)
[docs] def update_margins(self):
"""
Updates the current layer offset
:return:
"""
self.margins = [margin for margin in self.border_margins]
self.margins[1] += self.title_height
self.layer_pos = Pos2D(self.margins[0], self.margins[1])
[docs] def set_title(self, title: str | None) -> Plot:
"""
Sets the plot's title
:param title: The plot's title text. None do deactivate the title
:return: The plot
"""
figure = self.get_figure()
self.title = title
if self.title is None:
self.title_height = 0.0
return self
self.title_spacing = figure.default_plot_title_spacing
self._title_font = \
FontRegistry.get_font(figure.default_font,
size=figure.default_plot_title_size)
"The title's size in pixels"
text_size = self._title_font.get_text_size(title)
self.title_height = (text_size.height + self.title_spacing[0] +
self.title_spacing[1])
self.update_margins()
return self
[docs] def update_layout(self):
"""
Updates the plot's layout to compute the plot's efficient size
"""
margins = self.margins
hor_margins = margins[0] + margins[2]
ver_margins = margins[1] + margins[3]
plot_default_size: Size2D = self.get_figure().plot_default_size
if self.target_size is not None:
self.size = self.target_size
forced_size = Size2D(self.size.width - hor_margins,
self.size.width - ver_margins)
for layer in self.layers:
layer.update_layout(forced_size=forced_size)
elif len(self.layers) == 0:
self.size = Size2D(0, 0)
self.layer_size = self.size
else:
self.size = plot_default_size
desired_size = Size2D(plot_default_size.width - hor_margins,
plot_default_size.width - ver_margins)
for layer in self.layers:
if layer.fixed_size is not None:
desired_size = layer.fixed_size
for layer in self.layers:
layer.update_layout(desired_size=desired_size)
if len(self.layers):
layer_zero = self.layers[0]
self.size = Size2D(layer_zero.size.width + hor_margins,
layer_zero.size.height + ver_margins)
self.layer_size = layer_zero.size
else:
self.layer_size = Size2D(0, 0)
[docs] def paint(self, canvas: Canvas):
"""
Paints the plot and all of it's layers and subcomponents such as
the legend.
:param canvas: The target canvas
"""
self._paint_decoration(canvas)
if not self.size.is_empty():
clipping = any([layer.needs_clipping for layer in self.layers])
layer_offset = self.layer_pos.to_int_tuple()
org_canvas = canvas
if clipping:
first_layer = self.layers[0]
canvas_size = first_layer.size.to_int_tuple()
image = canvas.to_image()
off = canvas.shift_position_by_offset(layer_offset)
image = image.cropped(box=(off[0],
off[1],
off[0] + canvas_size[0],
off[1] + canvas_size[1]))
canvas = image.to_canvas()
for layer in self.layers:
if not clipping:
canvas.push_state()
canvas.add_offset_shift(layer_offset)
layer.paint(canvas)
if not clipping:
canvas.pop_state()
if clipping:
org_canvas.draw_image(canvas.to_image(), layer_offset)
[docs] def _paint_decoration(self, canvas):
"""
Paints the decoration such as border and title
:param canvas: The target canvas
"""
margins = self.margins
if self.border_width != 0.0 and not self.size.is_empty():
ul = (margins[0] - self.border_margins[0],
margins[1] - self.border_margins[1])
lr = (self.size.width - margins[2] + self.border_margins[2],
self.size.height - margins[3] + self.border_margins[3])
canvas.rect(bounding=
Bounding2D(pos=ul,
lr=lr),
color=None,
outline_width=int(round(self.border_width)),
outline_color=self.border_color)
if self.title is not None:
text_size = self._title_font.get_text_size(self.title)
center_x = (self.layer_pos.x + self.layer_size.width / 2 -
text_size.width / 2)
y_off = self.title_spacing[0] + self._title_font.ascend // 2
canvas.text(Pos2D(center_x, y_off),
font=self._title_font,
v_align=VTextAlignment.CENTER,
text=self.title, color=self.title_color)
[docs] def render(self) -> Image:
"""
Renders the plot's figure
:return: The visualization of the plot as :class:`Image`
"""
self.get_figure()
return self._figure.render()
[docs] def add_image(self,
image: Image | np.ndarray,
size_ratio: float | tuple[
float, float] | None = None,
bg_fill: str | Color | None = "cb") -> Plot:
"""
Adds an image layer
:param image: The image to plot
:param size_ratio: The size ratio from the image's or
matrix original size to effective pixels. This can be used to
plot images in original size rather than being auto-scaled through
the plot's configuration.
- 1.0 = keep the original size.
- 0.5 = 50% of the original size
- ...
:param bg_fill: Defines the background which shall be used if
the image is alpha-transparent. If a color is passed the
whole background will be filled with given color.
"cb" (checkerboard) by default.
"""
from scistag.plotstag.layers.image_layer import ImageLayer
self.add_layer(ImageLayer(image=image,
size_ratio=size_ratio,
bg_fill=bg_fill))
return self
[docs] def add_matplot(self, figure: plt.Figure | None = None,
size_ratio: float | None = None,
**params):
"""
Adds a matplotlib figure as image layer to the plot
:param figure: The figure to be added
:param size_ratio: If set the plot will be scaled with given factor
:param params: The parameters to be passed to the figure if a new figure
shall be created.
:return: The Plot if a figure was passed (and so no further action is
required).
Otherwise an MPLayerLock which shall be used the following way:
.. code-block:: python
with my_plot.add_matplot() as plt:
plt.title(...)
plt.imshow(...)
"""
from scistag.plotstag.matplot_helper import MPHelper
from scistag.plotstag.layers.matplot_layer import MPLayerLock
if figure is not None:
self.add_image(MPHelper.figure_to_image(figure),
size_ratio=size_ratio,
bg_fill=None)
else:
return MPLayerLock(self, size_ratio=size_ratio, **params)