# -*- coding: utf-8 -*-
#
# Licensed under the terms of the BSD 3-Clause
# (see plotpy/LICENSE for details)
# pylint: disable=C0103
"""
Plotting widget base class
--------------------------
The `base` module provides the :mod:`plotpy` plotting widget base class:
:py:class:`.base.BasePlot`.
"""
from __future__ import annotations
import dataclasses
import pickle
import sys
import warnings
import weakref
from datetime import datetime
from math import fabs
from typing import TYPE_CHECKING, Any
import numpy as np
import qwt
from guidata.configtools import get_font
from guidata.qthelpers import is_qobject_valid
from qtpy import QtCore as QC
from qtpy import QtGui as QG
from qtpy import QtWidgets as QW
from qtpy.QtPrintSupport import QPrinter
from plotpy import constants as cst
from plotpy import io
from plotpy.config import CONF, _
from plotpy.constants import PARAMETERS_TITLE_ICON, PlotType
from plotpy.events import StatefulEventFilter
from plotpy.interfaces import items as itf
from plotpy.items import (
AnnotatedCircle,
AnnotatedEllipse,
AnnotatedObliqueRectangle,
AnnotatedPoint,
AnnotatedPolygon,
AnnotatedRectangle,
AnnotatedSegment,
BaseImageItem,
CurveItem,
GridItem,
Marker,
PolygonMapItem,
PolygonShape,
QuiverItem,
)
from plotpy.styles.axes import AxesParam, AxeStyleParam, AxisParam, ImageAxesParam
from plotpy.styles.base import GridParam, ItemParameters
if TYPE_CHECKING:
from typing import IO
from qwt.scale_widget import QwtScaleWidget
from plotpy.plot.manager import PlotManager
TrackableItem = CurveItem | BaseImageItem
import guidata.io
[docs]
@dataclasses.dataclass
class BasePlotOptions:
"""Base plot options
Args:
title: The plot title
xlabel: (bottom axis title, top axis title) or bottom axis title only
ylabel: (left axis title, right axis title) or left axis title only
zlabel: The Z-axis label
xunit: (bottom axis unit, top axis unit) or bottom axis unit only
yunit: (left axis unit, right axis unit) or left axis unit only
zunit: The Z-axis unit
yreverse: If True, the Y-axis is reversed
aspect_ratio: The plot aspect ratio
lock_aspect_ratio: If True, the aspect ratio is locked
curve_antialiasing: If True, the curve antialiasing is enabled
gridparam: The grid parameters
section: The plot configuration section name ("plot", by default)
type: The plot type ("auto", "manual", "curve" or "image")
axes_synchronised: If True, the axes are synchronised
force_colorbar_enabled: If True, the colorbar is always enabled
show_axes_tab: If True, the axes tab is shown in the parameters dialog
autoscale_margin_percent: The percentage margin added when autoscaling
(0.2% by default)
"""
title: str | None = None
xlabel: str | tuple[str, str] | None = None
ylabel: str | tuple[str, str] | None = None
zlabel: str | None = None
xunit: str | tuple[str, str] | None = None
yunit: str | tuple[str, str] | None = None
zunit: str | None = None
yreverse: bool | None = None
aspect_ratio: float = 1.0
lock_aspect_ratio: bool | None = None
curve_antialiasing: bool | None = None
gridparam: GridParam | None = None
section: str = "plot"
type: str | PlotType = "auto"
axes_synchronised: bool = False
force_colorbar_enabled: bool = False
show_axes_tab: bool = True
autoscale_margin_percent: float = 0.2
def __post_init__(self) -> None:
"""Check arguments"""
# Check type
if isinstance(self.type, str):
if self.type not in ["auto", "manual", "curve", "image"]:
raise ValueError("type must be 'auto', 'manual', 'curve' or 'image'")
self.type = PlotType[self.type.upper()]
elif not isinstance(self.type, PlotType):
raise TypeError("type must be a string or a PlotType")
# Check aspect ratio
if self.aspect_ratio <= 0:
raise ValueError("aspect_ratio must be strictly positive")
# Check autoscale margin percentage
if self.autoscale_margin_percent < 0:
raise ValueError("autoscale_margin_percent must be non-negative")
if self.autoscale_margin_percent > 50:
raise ValueError("autoscale_margin_percent must be <= 50%")
# Show a warning if force_colorbar_enabled is True and type is "curve"
if self.force_colorbar_enabled and self.type == "curve":
warnings.warn(
"force_colorbar_enabled is True but type is 'curve', "
"so the colorbar will not be displayed",
RuntimeWarning,
)
[docs]
def copy(self, other_options: dict[str, Any]) -> BasePlotOptions:
"""Copy the options and replace some of them with the given dictionary
Args:
other_options: The dictionary
Returns:
BasePlotOptions: The new options
"""
return dataclasses.replace(self, **other_options)
[docs]
class BasePlot(qwt.QwtPlot):
"""Enhanced QwtPlot class providing methods for handling items and axes better
It distinguishes activatable items from basic QwtPlotItems.
Activatable items must support IBasePlotItem interface and should
be added to the plot using add_item methods.
Args:
parent: parent widget
options: plot options
"""
Y_LEFT, Y_RIGHT, X_BOTTOM, X_TOP = cst.Y_LEFT, cst.Y_RIGHT, cst.X_BOTTOM, cst.X_TOP
AXIS_IDS = (Y_LEFT, Y_RIGHT, X_BOTTOM, X_TOP)
AXIS_NAMES = {"left": Y_LEFT, "right": Y_RIGHT, "bottom": X_BOTTOM, "top": X_TOP}
AXIS_TYPES = {
"lin": qwt.QwtLinearScaleEngine,
"log": qwt.QwtLogScaleEngine,
"datetime": qwt.QwtDateTimeScaleEngine,
}
AXIS_CONF_OPTIONS = ("axis", "axis", "axis", "axis")
DEFAULT_ACTIVE_XAXIS = X_BOTTOM
DEFAULT_ACTIVE_YAXIS = Y_LEFT
AUTOSCALE_TYPES = (CurveItem, BaseImageItem, PolygonMapItem, QuiverItem)
#: Signal emitted by plot when an IBasePlotItem object was moved
#:
#: Args:
#: item: the moved item
#: x0 (float): the old x position
#: y0 (float): the old y position
#: x1 (float): the new x position
#: y1 (float): the new y position
SIG_ITEM_MOVED = QC.Signal(object, float, float, float, float)
#: Signal emitted by plot when an IBasePlotItem handle was moved
#:
#: Args:
#: item: the moved item
SIG_ITEM_HANDLE_MOVED = QC.Signal(object)
#: Signal emitted by plot when an IBasePlotItem object was resized
#:
#: Args:
#: item: the resized item
#: zoom_dx (float): the zoom factor along the x axis
#: zoom_dy (float): the zoom factor along the y axis
SIG_ITEM_RESIZED = QC.Signal(object, float, float)
#: Signal emitted by plot when an IBasePlotItem object was rotated
#:
#: Args:
#: item: the rotated item
#: angle (float): the new angle (in radians)
SIG_ITEM_ROTATED = QC.Signal(object, float)
#: Signal emitted by plot when a shape.Marker position changes
#:
#: Args:
#: marker: the moved marker
SIG_MARKER_CHANGED = QC.Signal(object)
#: Signal emitted by plot when a shape.Axes position (or the angle) changes
#:
#: Args:
#: axes: the moved axes
SIG_AXES_CHANGED = QC.Signal(object)
#: Signal emitted by plot when an annotation.AnnotatedShape position changes
#:
#: Args:
#: annotation: the moved annotation
SIG_ANNOTATION_CHANGED = QC.Signal(object)
#: Signal emitted by plot when the a shape.XRangeSelection range changes
#:
#: Args:
#: selection: the selection item
#: xmin (float): the new minimum x value
#: xmax (float): the new maximum x value
SIG_RANGE_CHANGED = QC.Signal(object, float, float)
#: Signal emitted by plot when item list has changed (item removed, added, ...)
#:
#: Args:
#: plot: the plot
SIG_ITEMS_CHANGED = QC.Signal(object)
#: Signal emitted by plot when item parameters have changed (through the item's
#: parameters dialog, or when setting colormap using the dedicated tool)
#:
#: Args:
#: item: the item
SIG_ITEM_PARAMETERS_CHANGED = QC.Signal(object)
#: Signal emitted by plot when axis parameters have changed (through the axis
#: parameters dialog)
#:
#: Args:
#: axis_id: the axis id (0: left, 1: right, 2: bottom, 3: top)
SIG_AXIS_PARAMETERS_CHANGED = QC.Signal(int)
#: Signal emitted by plot when selected item has changed
#:
#: Args:
#: plot: the plot
SIG_ACTIVE_ITEM_CHANGED = QC.Signal(object)
#: Signal emitted by plot when an item was deleted from the item list or using the
#: delete item tool
#:
#: Args:
#: item: the deleted item
SIG_ITEM_REMOVED = QC.Signal(object)
#: Signal emitted by plot when an item is selected
#:
#: Args:
#: item: the selected item
SIG_ITEM_SELECTION_CHANGED = QC.Signal(object)
#: Signal emitted by plot when plot's title or any axis label has changed
#:
#: Args:
#: plot: the plot
SIG_PLOT_LABELS_CHANGED = QC.Signal(object)
#: Signal emitted by plot when any plot axis direction has changed
#:
#: Args:
#: plot: the plot
#: axis_id: the axis id ("left", "right", "bottom", "top")
SIG_AXIS_DIRECTION_CHANGED = QC.Signal(object, object)
#: Signal emitted by plot when LUT has been changed by the user
#:
#: Args:
#: plot: the plot
SIG_LUT_CHANGED = QC.Signal(object)
#: Signal emitted by plot when image mask has changed
#:
#: Args:
#: plot: the plot
SIG_MASK_CHANGED = QC.Signal(object)
#: Signal emitted by cross section plot when cross section curve data has changed
#:
#: Args:
#: plot: the plot
SIG_CS_CURVE_CHANGED = QC.Signal(object)
#: Signal emitted by plot when plot axis has changed, e.g. when panning/zooming
#:
#: Args:
#: plot: the plot
SIG_PLOT_AXIS_CHANGED = QC.Signal(object)
EPSILON_ASPECT_RATIO = 1e-6
def __init__(
self,
parent: QW.QWidget | None = None,
options: BasePlotOptions | dict[str, Any] | None = None,
) -> None:
super().__init__(parent)
if isinstance(options, dict):
options = BasePlotOptions(**options)
self.options = options = options if options is not None else BasePlotOptions()
self.__autoscale_excluded_items: list[itf.IBasePlotItem] = []
self.autoscale_margin_percent = options.autoscale_margin_percent
self._axis_autoscale_strategy: dict[
int, tuple[str, float | None, float | None]
] = {axis_id: ("auto", None, None) for axis_id in self.AXIS_IDS}
self.lock_aspect_ratio = options.lock_aspect_ratio
self.__autoLockAspectRatio = False
if self.lock_aspect_ratio is None:
if self.options.type == PlotType.IMAGE:
self.lock_aspect_ratio = True
elif self.options.type in (PlotType.CURVE, PlotType.MANUAL):
self.lock_aspect_ratio = False
else: # PlotType.AUTO
self.lock_aspect_ratio = False
self.__autoLockAspectRatio = True
self.__autoYReverse = False
if options.yreverse is None:
if self.options.type == PlotType.IMAGE:
options.yreverse = True
elif self.options.type in (PlotType.CURVE, PlotType.MANUAL):
options.yreverse = False
else: # PlotType.AUTO
options.yreverse = False
self.__autoYReverse = True
self.colormap_axis = cst.Y_RIGHT
self.__autoColorBarEnabled = False
if options.force_colorbar_enabled or self.options.type == PlotType.IMAGE:
self.enableAxis(self.colormap_axis)
self.axisWidget(self.colormap_axis).setColorBarEnabled(True)
elif self.options.type == PlotType.AUTO:
self.__autoColorBarEnabled = True
if options.zlabel is not None:
if options.ylabel is not None and not isinstance(options.ylabel, str):
options.ylabel = options.ylabel[0]
options.ylabel = (options.ylabel, options.zlabel)
if options.zunit is not None:
if options.yunit is not None and not isinstance(options.yunit, str):
options.yunit = options.yunit[0]
options.yunit = (options.yunit, options.zunit)
self._start_autoscaled = True
self.setSizePolicy(QW.QSizePolicy.Expanding, QW.QSizePolicy.Expanding)
self.manager = None
self.plot_id = None # id assigned by it's manager
self.filter = StatefulEventFilter(self)
self.items: list[itf.IBasePlotItem] = []
self.active_item: qwt.QwtPlotItem = None
self.last_selected = {} # a mapping from item type to last selected item
self.axes_styles = [
AxeStyleParam(_("Left")),
AxeStyleParam(_("Right")),
AxeStyleParam(_("Bottom")),
AxeStyleParam(_("Top")),
]
self._active_xaxis = self.DEFAULT_ACTIVE_XAXIS
self._active_yaxis = self.DEFAULT_ACTIVE_YAXIS
self.read_axes_styles(options.section, self.AXIS_CONF_OPTIONS)
self.font_title = get_font(CONF, options.section, "title")
canvas: qwt.QwtPlotCanvas = self.canvas()
canvas.setFocusPolicy(QC.Qt.FocusPolicy.StrongFocus)
canvas.setFocusIndicator(qwt.QwtPlotCanvas.ItemFocusIndicator)
self.SIG_ITEM_MOVED.connect(self._move_selected_items_together)
self.SIG_ITEM_RESIZED.connect(self._resize_selected_items_together)
self.SIG_ITEM_ROTATED.connect(self._rotate_selected_items_together)
self.legendDataChanged.connect(
lambda item, _legdata: item.update_item_parameters()
)
self.axes_reverse = [False] * 4
self.set_titles(
title=options.title,
xlabel=options.xlabel,
ylabel=options.ylabel,
xunit=options.xunit,
yunit=options.yunit,
)
self.antialiased = False
antial = options.curve_antialiasing or CONF.get(options.section, "antialiasing")
self.set_antialiasing(antial)
self.axes_synchronised = options.axes_synchronised
# Installing our own event filter:
# (qwt's event filter does not fit our needs)
canvas.installEventFilter(self.filter)
canvas.setMouseTracking(True)
self.cross_marker = Marker()
self.curve_marker = Marker(
label_cb=self.get_coordinates_str, constraint_cb=self.on_active_curve
)
self.__marker_stay_visible = False
self.cross_marker.set_style(options.section, "marker/cross")
self.curve_marker.set_style(options.section, "marker/curve")
self.cross_marker.setVisible(False)
self.curve_marker.setVisible(False)
self.cross_marker.attach(self)
self.curve_marker.attach(self)
# Background color
self.setCanvasBackground(QC.Qt.GlobalColor.white)
self.curve_pointer = False
self.canvas_pointer = False
# Setting up grid
if options.gridparam is None:
options.gridparam = GridParam(title=_("Grid"), icon="grid.png")
options.gridparam.read_config(CONF, options.section, "grid")
self.grid = GridItem(options.gridparam)
self.add_item(self.grid, z=-1)
self.__aspect_ratio = None
self.set_axis_direction("left", options.yreverse)
self.set_aspect_ratio(options.aspect_ratio, options.lock_aspect_ratio)
self.replot() # Workaround for the empty image widget bug
# ---- Private API ----------------------------------------------------------
def __del__(self):
# Sometimes, an obscure exception happens when we quit an application
# because if we don't remove the eventFilter it can still be called
# after the filter object has been destroyed by Python.
# Note: PySide6 segfaults instead of raising RuntimeError when accessing
# a deleted C++ object, so we must check validity before calling methods.
if not is_qobject_valid(self):
return
canvas: qwt.QwtPlotCanvas = self.canvas()
if canvas and is_qobject_valid(canvas) and is_qobject_valid(self.filter):
try:
canvas.removeEventFilter(self.filter)
except (RuntimeError, ValueError):
# Widget/filter may have already been deleted
pass
[docs]
def update_color_mode(self) -> None:
"""Color mode was updated, update plot widget accordingly"""
self.grid.gridparam.read_config(CONF, self.options.section, "grid")
self.grid.gridparam.update_grid(self.grid)
self.replot()
[docs]
def on_active_curve(self, x: float, y: float) -> tuple[float, float]:
"""
Callback called when the active curve is moved
Args:
x (float): the x position
y (float): the y position
"""
curve: CurveItem = self.get_last_active_item(itf.ICurveItemType)
if curve:
x, y = curve.get_closest_coordinates(x, y)
return x, y
[docs]
def get_coordinates_str(self, x: float, y: float) -> str:
"""
Return the coordinates string
Args:
x (float): the x position
y (float): the y position
Returns:
str: the coordinates string
"""
title = _("Grid")
item: TrackableItem = self.get_last_active_item(itf.ITrackableItemType)
if item:
return item.get_coordinates_label(x, y)
return self.format_coordinate_values(x, y, "bottom", "left", title)
[docs]
def set_marker_axes(self) -> None:
"""
Set the axes of the markers
"""
item: TrackableItem = self.get_last_active_item(itf.ITrackableItemType)
if item:
self.cross_marker.setAxes(item.xAxis(), item.yAxis())
self.curve_marker.setAxes(item.xAxis(), item.yAxis())
[docs]
def do_move_marker(self, event: QG.QMouseEvent) -> None:
"""
Move the marker
Args:
event (QMouseEvent): the event
"""
pos = event.pos()
self.set_marker_axes()
if (
event.modifiers() & QC.Qt.KeyboardModifier.ShiftModifier
or self.curve_pointer
):
self.curve_marker.setZ(self.get_max_z() + 1)
self.cross_marker.setVisible(False)
self.curve_marker.setVisible(True)
self.curve_marker.move_local_point_to(0, pos)
self.replot()
self.__marker_stay_visible = event.modifiers() & QC.Qt.ControlModifier
elif (
event.modifiers() & QC.Qt.KeyboardModifier.AltModifier
or self.canvas_pointer
):
self.cross_marker.setZ(self.get_max_z() + 1)
self.cross_marker.setVisible(True)
self.curve_marker.setVisible(False)
self.cross_marker.move_local_point_to(0, pos)
self.replot()
self.__marker_stay_visible = event.modifiers() & QC.Qt.ControlModifier
else:
vis_cross = self.cross_marker.isVisible()
vis_curve = self.curve_marker.isVisible()
self.cross_marker.setVisible(False)
self.curve_marker.setVisible(self.__marker_stay_visible)
if vis_cross or vis_curve:
self.replot()
def __get_axes_to_update(
self,
dx: tuple[float, float, float, float],
dy: tuple[float, float, float, float],
) -> list[tuple[float, float, float, float], int]:
"""
Return the axes to update
Args:
dx (tuple[float, float, float, float]): the x axis 'state' tuple
dy (tuple[float, float, float, float]): the y axis 'state' tuple
Returns:
list[tuple[float, float, float, float], int]: the axes to update
"""
if self.axes_synchronised:
axes = []
for axis_name in self.AXIS_NAMES:
if axis_name in ("left", "right"):
d = dy
else:
d = dx
axes.append((d, self.get_axis_id(axis_name)))
return axes
else:
xaxis, yaxis = self.get_active_axes()
return [(dx, xaxis), (dy, yaxis)]
[docs]
def do_pan_view(
self,
dx: tuple[float, float, float, float],
dy: tuple[float, float, float, float],
replot: bool = True,
) -> None:
"""
Translate the active axes according to dx, dy axis 'state' tuples
Args:
dx (tuple[float, float, float, float]): the x axis 'state' tuple
dy (tuple[float, float, float, float]): the y axis 'state' tuple
replot: if True, do a full replot else just update the axes to avoid a
redraw (default: True)
"""
# dx and dy are the output of the "DragHandler.get_move_state" method
# (see module ``plotpy.events``)
auto = self.autoReplot()
self.setAutoReplot(False)
axes_to_update = self.__get_axes_to_update(dx, dy)
for (x1, x0, _start, _width), axis_id in axes_to_update:
lbound, hbound = self.get_axis_limits(axis_id)
i_lbound = self.transform(axis_id, lbound)
i_hbound = self.transform(axis_id, hbound)
delta = x1 - x0
vmin = self.invTransform(axis_id, i_lbound - delta)
vmax = self.invTransform(axis_id, i_hbound - delta)
self.set_axis_limits(axis_id, vmin, vmax)
self.setAutoReplot(auto)
if replot:
self.replot()
else:
self.updateAxes()
# the signal MUST be emitted after replot, otherwise
# we receiver won't see the new bounds (don't know why?)
self.SIG_PLOT_AXIS_CHANGED.emit(self)
[docs]
def do_zoom_view(
self,
dx: tuple[float, float, float, float],
dy: tuple[float, float, float, float],
lock_aspect_ratio: bool | None = None,
replot: bool = True,
) -> None:
"""
Change the scale of the active axes (zoom/dezoom) according to dx, dy
axis 'state' tuples
We try to keep initial pos fixed on the canvas as the scale changes
Args:
dx (tuple[float, float, float, float]): the x axis 'state' tuple
dy (tuple[float, float, float, float]): the y axis 'state' tuple
lock_aspect_ratio: if True, the aspect ratio is locked
replot: if True, do a full replot else just update the axes to avoid a
redraw (default: True)
"""
# dx and dy are the output of the "DragHandler.get_move_state" method
# (see module ``plotpy.events``):
# dx = (pos.x(), self.last.x(), self.start.x(), rct.width())
# dy = (pos.y(), self.last.y(), self.start.y(), rct.height())
# where:
# * self.last is the mouse position seen during last event
# * self.start is the first mouse position (here, this is the
# coordinate of the point which is at the center of the zoomed area)
# * rct is the plot rect contents
# * pos is the current mouse cursor position
if lock_aspect_ratio is None:
lock_aspect_ratio = self.lock_aspect_ratio
auto = self.autoReplot()
self.setAutoReplot(False)
dx = (-1,) + dx # adding direction to tuple dx
dy = (1,) + dy # adding direction to tuple dy
if lock_aspect_ratio:
direction, x1, x0, start, width = dx
F = 1 + 3 * direction * float(x1 - x0) / width
axes_to_update = self.__get_axes_to_update(dx, dy)
for (direction, x1, x0, start, width), axis_id in axes_to_update:
lbound, hbound = self.get_axis_limits(axis_id)
if not lock_aspect_ratio:
F = 1 + 3 * direction * float(x1 - x0) / width
if F * (hbound - lbound) == 0:
continue
if self.get_axis_scale(axis_id) in ("lin", "datetime"):
orig = self.invTransform(axis_id, start)
vmin = orig - F * (orig - lbound)
vmax = orig + F * (hbound - orig)
else: # log scale
i_lbound = self.transform(axis_id, lbound)
i_hbound = self.transform(axis_id, hbound)
imin = start - F * (start - i_lbound)
imax = start + F * (i_hbound - start)
vmin = self.invTransform(axis_id, imin)
vmax = self.invTransform(axis_id, imax)
self.set_axis_limits(axis_id, vmin, vmax)
self.setAutoReplot(auto)
if replot:
self.replot()
else:
self.updateAxes()
# the signal MUST be emitted after replot, otherwise
# we receiver won't see the new bounds (don't know why?)
self.SIG_PLOT_AXIS_CHANGED.emit(self)
[docs]
def do_zoom_rect_view(self, start: QC.QPointF, end: QC.QPointF) -> None:
"""
Zoom to rectangle defined by start and end points
Args:
start (QPointF): the start point
end (QPointF): the end point
"""
# TODO: Implement the case when axes are synchronised
x1, y1 = start.x(), start.y()
x2, y2 = end.x(), end.y()
xaxis, yaxis = self.get_active_axes()
active_axes = [(x1, x2, xaxis), (y1, y2, yaxis)]
for h1, h2, k in active_axes:
o1 = self.invTransform(k, h1)
o2 = self.invTransform(k, h2)
if o1 > o2:
o1, o2 = o2, o1
if o1 == o2:
continue
if self.get_axis_direction(k):
o1, o2 = o2, o1
self.setAxisScale(k, o1, o2)
self.replot()
self.SIG_PLOT_AXIS_CHANGED.emit(self)
if self.lock_aspect_ratio:
self.apply_aspect_ratio()
[docs]
def get_default_item(self) -> itf.IBasePlotItem | None:
"""Return default item, depending on plot's default item type
(e.g. for a curve plot, this is a curve item type).
Return nothing if there is more than one item matching
the default item type.
Returns:
IBasePlotItem: the default item
"""
if self.options.type == PlotType.IMAGE:
items = self.get_items(item_type=itf.IImageItemType)
elif self.options.type == PlotType.CURVE:
items = self.get_items(item_type=itf.ICurveItemType)
else:
items = [
item
for item in self.items
if itf.IImageItemType in item.types()
or itf.ICurveItemType in item.types()
]
if len(items) == 1:
return items[0]
# ---- QWidget API ---------------------------------------------------------
[docs]
def mouseDoubleClickEvent(self, event: QG.QMouseEvent) -> None:
"""Reimplement QWidget method"""
for axis_id in self.AXIS_IDS:
widget = self.axisWidget(axis_id)
if widget.geometry().contains(event.pos()):
self.edit_axis_parameters(axis_id)
break
else:
qwt.QwtPlot.mouseDoubleClickEvent(self, event)
# ---- QwtPlot API ---------------------------------------------------------
[docs]
def showEvent(self, event) -> None:
"""Reimplement Qwt method"""
if self.lock_aspect_ratio:
self._start_autoscaled = True
qwt.QwtPlot.showEvent(self, event)
if self._start_autoscaled:
self.do_autoscale()
[docs]
def resizeEvent(self, event):
"""Reimplement Qt method to resize widget"""
qwt.QwtPlot.resizeEvent(self, event)
if self.lock_aspect_ratio:
self.apply_aspect_ratio()
self.replot()
# ---- Public API ----------------------------------------------------------
def _move_selected_items_together(
self, item: itf.IBasePlotItem, x0: float, y0: float, x1: float, y1: float
) -> None:
"""Selected items move together
Args:
item (IBasePlotItem): the item
x0 (float): the old x position
y0 (float): the old y position
x1 (float): the new x position
y1 (float): the new y position
"""
for selitem in self.get_selected_items():
if selitem is not item and selitem.can_move():
selitem.move_with_selection(x1 - x0, y1 - y0)
def _resize_selected_items_together(
self, item: itf.IBasePlotItem, zoom_dx: float, zoom_dy: float
) -> None:
"""Selected items resize together
Args:
item (IBasePlotItem): the item
zoom_dx (float): the zoom factor along the x axis
zoom_dy (float): the zoom factor along the y axis
"""
for selitem in self.get_selected_items():
if (
selitem is not item
and selitem.can_resize()
and itf.IBaseImageItem in selitem.__implements__
):
if zoom_dx != 0 or zoom_dy != 0:
selitem.resize_with_selection(zoom_dx, zoom_dy)
def _rotate_selected_items_together(
self, item: itf.IBasePlotItem, angle: float
) -> None:
"""Selected items rotate together
Args:
item (IBasePlotItem): the item
angle (float): the new angle (in radians)
"""
for selitem in self.get_selected_items():
if (
selitem is not item
and selitem.can_rotate()
and itf.IBaseImageItem in selitem.__implements__
):
selitem.rotate_with_selection(angle)
[docs]
def set_manager(self, manager: PlotManager, plot_id: int) -> None:
"""Set the associated :py:class:`.plot.manager.PlotManager` instance
Args:
manager (PlotManager): the manager
plot_id (int): the plot id
"""
self.manager = manager
self.plot_id = plot_id
[docs]
def sizeHint(self) -> QC.QSize:
"""Preferred size"""
return QC.QSize(400, 300)
[docs]
def get_title(self) -> str:
"""Get plot title"""
return str(self.title().text())
[docs]
def set_title(self, title: str) -> None:
"""Set plot title
Args:
title (str): the title
"""
text = qwt.QwtText(title)
text.setFont(self.font_title)
self.setTitle(text)
self.SIG_PLOT_LABELS_CHANGED.emit(self)
[docs]
def get_show_axes_tab(self) -> bool:
"""Get whether the axes tab is shown in the parameters dialog
Returns:
bool: True if the axes tab is shown
"""
return self.options.show_axes_tab
[docs]
def set_show_axes_tab(self, show: bool) -> None:
"""Set whether the axes tab is shown in the parameters dialog
Args:
show (bool): True to show the axes tab
"""
self.options.show_axes_tab = show
[docs]
def get_axis_id(self, axis_name: str | int) -> int:
"""Return axis ID from axis name
If axis ID is passed directly, check the ID
Args:
axis_name (str | int): the axis name or ID
Returns:
int: the axis ID
"""
assert axis_name in self.AXIS_NAMES or axis_name in self.AXIS_IDS
return self.AXIS_NAMES.get(axis_name, axis_name)
[docs]
def read_axes_styles(self, section: str, options: list[str, str, str, str]) -> None:
"""
Read axes styles from section and options (one option
for each axis in the order left, right, bottom, top)
Skip axis if option is None
Args:
section (str): the section
options (list[str, str, str, str]): the options
"""
for prm, option in zip(self.axes_styles, options):
if option is None:
continue
prm.read_config(CONF, section, option)
self.update_all_axes_styles()
[docs]
def get_axis_title(self, axis_id: int) -> str:
"""Get axis title
Args:
axis_id (int): the axis id
Returns:
str: the axis title
"""
axis_id = self.get_axis_id(axis_id)
return self.axes_styles[axis_id].title
[docs]
def set_axis_title(self, axis_id: int, text: str) -> None:
"""Set axis title
Args:
axis_id (int): the axis id
text (str): the axis title
"""
axis_id = self.get_axis_id(axis_id)
self.axes_styles[axis_id].title = text
self.update_axis_style(axis_id)
[docs]
def get_axis_unit(self, axis_id: int) -> str:
"""Get axis unit
Args:
axis_id (int): the axis id
Returns:
str: the axis unit
"""
axis_id = self.get_axis_id(axis_id)
return self.axes_styles[axis_id].unit
[docs]
def set_axis_unit(self, axis_id: int, text: str) -> None:
"""Set axis unit
Args:
axis_id (int): the axis id
text (str): the axis unit
"""
axis_id = self.get_axis_id(axis_id)
self.axes_styles[axis_id].unit = text
self.update_axis_style(axis_id)
[docs]
def get_axis_font(self, axis_id: int) -> QG.QFont:
"""Get axis font
Args:
axis_id (int): the axis id
Returns:
QFont: the axis font
"""
axis_id = self.get_axis_id(axis_id)
return self.axes_styles[axis_id].title_font.build_font()
[docs]
def set_axis_font(self, axis_id: int, font: QG.QFont) -> None:
"""Set axis font
Args:
axis_id (int): the axis id
font (QFont): the axis font
"""
axis_id = self.get_axis_id(axis_id)
self.axes_styles[axis_id].title_font.update_param(font)
self.axes_styles[axis_id].ticks_font.update_param(font)
self.update_axis_style(axis_id)
[docs]
def get_axis_color(self, axis_id: int) -> str:
"""Get axis color (color name, i.e. string)
Args:
axis_id (int): the axis id
Returns:
str: the axis color
"""
axis_id = self.get_axis_id(axis_id)
return self.axes_styles[axis_id].color
[docs]
def set_axis_color(self, axis_id: int, color: str | QG.QColor) -> None:
"""
Set axis color
Args:
axis_id (int): the axis id
color (str | QColor): the axis color
"""
axis_id = self.get_axis_id(axis_id)
if isinstance(color, str):
color = QG.QColor(color)
self.axes_styles[axis_id].color = str(color.name())
self.update_axis_style(axis_id)
[docs]
def update_axis_style(self, axis_id: int) -> None:
"""Update axis style
Args:
axis_id (int): the axis id
"""
axis_id = self.get_axis_id(axis_id)
style = self.axes_styles[axis_id]
title_font = style.title_font.build_font()
ticks_font = style.ticks_font.build_font()
self.setAxisFont(axis_id, ticks_font)
if style.title and style.unit:
title = f"{style.title} ({style.unit})"
elif style.title:
title = style.title
else:
title = style.unit
axis_text = self.axisTitle(axis_id)
axis_text.setFont(title_font)
axis_text.setText(title)
axis_text.setColor(QG.QColor(style.color))
self.setAxisTitle(axis_id, axis_text)
self.SIG_PLOT_LABELS_CHANGED.emit(self)
[docs]
def update_all_axes_styles(self) -> None:
"""Update all axes styles"""
for axis_id in self.AXIS_IDS:
self.update_axis_style(axis_id)
[docs]
def get_axis_limits(self, axis_id: int) -> tuple[float, float]:
"""Return axis limits (minimum and maximum values)
Args:
axis_id (int): the axis id
Returns:
tuple[float, float]: the axis limits
"""
axis_id = self.get_axis_id(axis_id)
sdiv = self.axisScaleDiv(axis_id)
return sdiv.lowerBound(), sdiv.upperBound()
[docs]
def set_axis_limits(
self, axis_id: int, vmin: float, vmax: float, stepsize: int = 0
) -> None:
"""Set axis limits (minimum and maximum values) and step size
Args:
axis_id (int): the axis id
vmin (float): the minimum value
vmax (float): the maximum value
stepsize (int): the step size (optional, default=0)
"""
axis_id = self.get_axis_id(axis_id)
vmin, vmax = sorted([vmin, vmax])
if self.get_axis_direction(axis_id):
self.setAxisScale(axis_id, vmax, vmin, stepsize)
else:
self.setAxisScale(axis_id, vmin, vmax, stepsize)
self._start_autoscaled = False
[docs]
def set_axis_ticks(
self, axis_id: int, nmajor: int | None = None, nminor: int | None = None
) -> None:
"""Set axis maximum number of major ticks and maximum of minor ticks
Args:
axis_id (int): the axis id
nmajor (int): the maximum number of major ticks
nminor (int): the maximum number of minor ticks
"""
axis_id = self.get_axis_id(axis_id)
if nmajor is not None:
self.setAxisMaxMajor(axis_id, nmajor)
if nminor is not None:
self.setAxisMaxMinor(axis_id, nminor)
[docs]
def get_axis_scale(self, axis_id: int) -> str:
"""Return the name ('lin', 'log', or 'datetime') of the scale used by axis
Args:
axis_id (int): the axis id
Returns:
str: the axis scale
"""
axis_id = self.get_axis_id(axis_id)
engine = self.axisScaleEngine(axis_id)
# Check for most specific types first, since datetime inherits from linear
if isinstance(engine, self.AXIS_TYPES["datetime"]):
return "datetime"
elif isinstance(engine, self.AXIS_TYPES["log"]):
return "log"
elif isinstance(engine, self.AXIS_TYPES["lin"]):
return "lin"
else:
return "lin" # unknown default to linear
[docs]
def set_axis_scale(self, axis_id: int, scale: str, autoscale: bool = True) -> None:
"""Set axis scale
Args:
axis_id (int): the axis id
scale (str): the axis scale ('lin', 'log', or 'datetime')
autoscale (bool): autoscale the axis (optional, default=True)
Example:
self.set_axis_scale(curve.yAxis(), 'lin')
self.set_axis_scale('bottom', 'datetime') # For time series data
"""
axis_id = self.get_axis_id(axis_id)
scale_engine = self.AXIS_TYPES[scale]()
if scale != self.get_axis_scale(axis_id):
if scale == "datetime":
self.setAxisScaleDraw(axis_id, qwt.QwtDateTimeScaleDraw())
else:
self.setAxisScaleDraw(axis_id, qwt.QwtScaleDraw())
self.setAxisScaleEngine(axis_id, scale_engine)
if autoscale:
self.do_autoscale(replot=False)
[docs]
def get_scales(self) -> tuple[str, str]:
"""Return active curve scales
Example:
self.get_scales() -> ('lin', 'lin')"""
ax, ay = self.get_active_axes()
return self.get_axis_scale(ax), self.get_axis_scale(ay)
[docs]
def set_scales(self, xscale: str, yscale: str) -> None:
"""Set active curve scales
Args:
xscale (str): the x axis scale ('lin', 'log' or 'datetime')
yscale (str): the y axis scale ('lin' or 'log')
Example:
self.set_scales('lin', 'lin')"""
ax, ay = self.get_active_axes()
self.set_axis_scale(ax, xscale)
self.set_axis_scale(ay, yscale)
self.replot()
[docs]
def set_axis_datetime(
self,
axis_id: int | str,
format: str = "%Y-%m-%d %H:%M:%S",
rotate: float = -45,
spacing: int = 4,
) -> None:
"""Configure an axis to display datetime labels
This method sets up an axis to display Unix timestamps as formatted
date/time strings.
Args:
axis_id: Axis ID (constants.Y_LEFT, constants.X_BOTTOM, ...)
or string: 'bottom', 'left', 'top' or 'right'
format: Format string for datetime display (default: "%Y-%m-%d %H:%M:%S").
Uses Python datetime.strftime() format codes.
rotate: Rotation angle for labels in degrees (default: -45)
spacing: Spacing between labels (default: 4)
Examples:
>>> # Enable datetime on x-axis with default format
>>> plot.set_axis_datetime("bottom")
>>> # Enable datetime with time only
>>> plot.set_axis_datetime("bottom", format="%H:%M:%S")
>>> # Enable datetime with date only, no rotation
>>> plot.set_axis_datetime("bottom", format="%Y-%m-%d", rotate=0)
"""
axis_id = self.get_axis_id(axis_id)
scale_draw = qwt.QwtDateTimeScaleDraw(format=format, spacing=spacing)
self.setAxisScaleDraw(axis_id, scale_draw)
scale_engine = qwt.QwtDateTimeScaleEngine()
self.setAxisScaleEngine(axis_id, scale_engine)
if rotate != 0:
self.setAxisLabelRotation(axis_id, rotate)
if rotate < 0:
self.setAxisLabelAlignment(axis_id, QC.Qt.AlignLeft | QC.Qt.AlignBottom)
else:
self.setAxisLabelAlignment(
axis_id, QC.Qt.AlignRight | QC.Qt.AlignBottom
)
self.do_autoscale()
[docs]
def set_axis_limits_from_datetime(
self,
axis_id: int | str,
dt_min: datetime,
dt_max: datetime,
stepsize: int = 0,
) -> None:
"""Set axis limits using datetime objects
This is a convenience method to set axis limits for datetime axes without
manually converting datetime objects to Unix timestamps.
Args:
axis_id: Axis ID (constants.Y_LEFT, constants.X_BOTTOM, ...)
or string: 'bottom', 'left', 'top' or 'right'
dt_min: Minimum datetime value
dt_max: Maximum datetime value
stepsize: The step size (optional, default=0)
Examples:
>>> from datetime import datetime
>>> # Set x-axis limits to a specific date range
>>> dt1 = datetime(2025, 10, 7, 10, 0, 0)
>>> dt2 = datetime(2025, 10, 7, 18, 0, 0)
>>> plot.set_axis_limits_from_datetime("bottom", dt1, dt2)
"""
if not isinstance(dt_min, datetime) or not isinstance(dt_max, datetime):
raise TypeError("dt_min and dt_max must be datetime objects")
# Convert datetime objects to Unix timestamps
epoch = datetime(1970, 1, 1)
timestamp_min = (dt_min - epoch).total_seconds()
timestamp_max = (dt_max - epoch).total_seconds()
# Set the axis limits using the timestamps
self.set_axis_limits(axis_id, timestamp_min, timestamp_max, stepsize)
[docs]
def get_autoscale_margin_percent(self) -> float:
"""Get autoscale margin percentage
Returns:
float: the autoscale margin percentage
"""
return self.autoscale_margin_percent
[docs]
def set_autoscale_margin_percent(self, margin_percent: float) -> None:
"""Set autoscale margin percentage
Args:
margin_percent (float): the autoscale margin percentage (0-50)
Raises:
ValueError: if margin_percent is not in valid range
"""
if margin_percent < 0:
raise ValueError("autoscale_margin_percent must be non-negative")
if margin_percent > 50:
raise ValueError("autoscale_margin_percent must be <= 50%")
self.autoscale_margin_percent = margin_percent
# Trigger an autoscale to apply the new margin immediately
self.do_autoscale(replot=True)
[docs]
def enable_used_axes(self):
"""
Enable only used axes
For now, this is needed only by the pyplot interface
"""
for axis in self.AXIS_IDS:
self.enableAxis(axis, True)
self.disable_unused_axes()
[docs]
def disable_unused_axes(self):
"""Disable unused axes"""
used_axes = set()
has_image = False
if self.options.type == PlotType.IMAGE or self.options.force_colorbar_enabled:
has_image = True
for item in self.get_items():
used_axes.add(item.xAxis())
used_axes.add(item.yAxis())
if not has_image and isinstance(item, BaseImageItem):
has_image = True
unused_axes = set(self.AXIS_IDS) - set(used_axes)
for axis in unused_axes:
self.enableAxis(axis, False)
if has_image:
self.enableAxis(self.colormap_axis)
[docs]
def get_items(
self,
z_sorted: bool = False,
item_type: type[itf.IItemType | itf.IBasePlotItem] | None = None,
) -> list[itf.IBasePlotItem]:
"""Return widget's item list
(items are based on IBasePlotItem's interface)
Args:
z_sorted (bool): sort items by z order (optional, default=False)
item_type (IItemType): the item type (optional, default=None)
Returns:
list[IBasePlotItem]: the item list
"""
if z_sorted:
items = sorted(self.items, reverse=True, key=lambda x: x.z())
else:
items = self.items
if item_type is None:
return items
else:
assert issubclass(item_type, itf.IItemType)
return [item for item in items if item_type in item.types()]
[docs]
def get_public_items(
self, z_sorted: bool = False, item_type: itf.IBasePlotItem | None = None
) -> list[itf.IBasePlotItem]:
"""Return widget's public item list
(items are based on IBasePlotItem's interface)
Args:
z_sorted (bool): sort items by z order (optional, default=False)
item_type (IItemType): the item type (optional, default=None)
Returns:
list[IBasePlotItem]: the item list
"""
return [
item
for item in self.get_items(z_sorted=z_sorted, item_type=item_type)
if not item.is_private()
]
[docs]
def get_private_items(
self, z_sorted: bool = False, item_type: itf.IBasePlotItem | None = None
) -> list[itf.IBasePlotItem]:
"""Return widget's private item list
(items are based on IBasePlotItem's interface)
Args:
z_sorted (bool): sort items by z order (optional, default=False)
item_type (IItemType): the item type (optional, default=None)
Returns:
list[IBasePlotItem]: the item list
"""
return [
item
for item in self.get_items(z_sorted=z_sorted, item_type=item_type)
if item.is_private()
]
[docs]
def copy_to_clipboard(self) -> None:
"""Copy widget's window to clipboard"""
clipboard = QW.QApplication.clipboard()
pixmap = self.grab()
clipboard.setPixmap(pixmap)
[docs]
def get_selected_items(
self, z_sorted=False, item_type: itf.IBasePlotItem | None = None
) -> list[itf.IBasePlotItem]:
"""Return selected items
Args:
z_sorted (bool): sort items by z order (optional, default=False)
item_type (IItemType): the item type (optional, default=None)
Returns:
list[IBasePlotItem]: the selected items
"""
return [
item
for item in self.get_items(item_type=item_type, z_sorted=z_sorted)
if item.selected
]
[docs]
def get_max_z(self) -> int:
"""
Return maximum z-order for all items registered in plot
If there is no item, return 0
Returns:
int: the maximum z-order
"""
if self.items:
return max([_it.z() for _it in self.items])
else:
return 0
[docs]
def add_item(
self, item: itf.IBasePlotItem, z: int | None = None, autoscale: bool = True
) -> None:
"""
Add a plot item instance to this plot widget
Args:
item (IBasePlotItem): the item
z (int): the z order (optional, default=None)
autoscale (bool): autoscale the plot (optional, default=True)
"""
if not hasattr(item, "__implements__"):
raise TypeError("item must implement IBasePlotItem interface")
assert itf.IBasePlotItem in item.__implements__
if isinstance(item, qwt.QwtPlotCurve):
item.setRenderHint(qwt.QwtPlotItem.RenderAntialiased, self.antialiased)
item.attach(self)
if z is not None:
z_exists = False
for _it in self.get_items():
if z == _it.z():
z_exists = True
break
if z_exists:
for _it in self.get_items():
if _it.z() > z:
_it.setZ(_it.z() + 1)
z += 1
item.setZ(z)
else:
item.setZ(self.get_max_z() + 1)
if item in self.items:
print(
"Warning: item %r is already attached to plot" % item, file=sys.stderr
)
else:
self.items.append(item)
# PlotType handling when adding the first CurveItem or ImageItem
if (
self.__autoLockAspectRatio
or self.__autoYReverse
or self.__autoColorBarEnabled
):
is_curve = isinstance(item, CurveItem)
is_image = isinstance(item, BaseImageItem)
if is_curve or is_image:
if self.__autoYReverse:
if is_image:
self.set_axis_direction("left", True)
self.__autoYReverse = False
if self.__autoLockAspectRatio:
if is_image:
self.set_aspect_ratio(lock=True)
self.__autoLockAspectRatio = False
if self.__autoColorBarEnabled:
if is_image:
self.axisWidget(self.colormap_axis).setColorBarEnabled(True)
self.enableAxis(self.colormap_axis)
self.__autoColorBarEnabled = False
self.SIG_ITEMS_CHANGED.emit(self)
if isinstance(item, BaseImageItem):
parent = self.parentWidget()
if parent is not None:
parent.setUpdatesEnabled(False)
self.update_colormap_axis(item)
if autoscale:
self.do_autoscale()
if parent is not None:
parent.setUpdatesEnabled(True)
[docs]
def add_item_with_z_offset(self, item: itf.IBasePlotItem, zoffset: int) -> None:
"""
Add a plot item instance within a specified z range, over an offset
Args:
item (IBasePlotItem): the item
zoffset (int): the z offset
"""
zlist = sorted(
[_it.z() for _it in self.items if _it.z() >= zoffset] + [zoffset - 1]
)
dzlist = np.argwhere(np.diff(zlist) > 1).flatten()
if len(dzlist) == 0:
z = max(zlist) + 1
else:
z = zlist[int(dzlist[0])] + 1
self.add_item(item, z=z)
def __clean_item_references(self, item: itf.IBasePlotItem) -> None:
"""Remove all reference to this item (active,
last_selected
Args:
item (IBasePlotItem): the item
"""
if item is self.active_item:
self.active_item: qwt.QwtPlotItem = None
self._active_xaxis = self.DEFAULT_ACTIVE_XAXIS
self._active_yaxis = self.DEFAULT_ACTIVE_YAXIS
for key, it in list(self.last_selected.items()):
if item is it:
del self.last_selected[key]
[docs]
def del_items(self, items: list[itf.IBasePlotItem]) -> None:
"""Remove item from widget
Args:
items (list[IBasePlotItem]): the items
"""
items = items[:] # copy the list to avoid side effects when we empty it
active_item = self.get_active_item()
while items:
item = items.pop()
item.detach()
# raises ValueError if item not in list
self.items.remove(item)
self.__clean_item_references(item)
self.SIG_ITEM_REMOVED.emit(item)
self.SIG_ITEMS_CHANGED.emit(self)
if active_item is not self.get_active_item():
self.SIG_ACTIVE_ITEM_CHANGED.emit(self)
[docs]
def del_item(self, item: itf.IBasePlotItem) -> None:
"""
Remove item from widget
Convenience function (see 'del_items')
Args:
item (IBasePlotItem): the item
"""
try:
self.del_items([item])
except ValueError:
raise ValueError("item not in plot")
[docs]
def set_item_visible(
self,
item: itf.IBasePlotItem,
state: bool,
notify: bool = True,
replot: bool = True,
) -> None:
"""Show/hide *item* and emit a SIG_ITEMS_CHANGED signal
Args:
item (IBasePlotItem): the item
state (bool): the visibility state
notify (bool): notify the item list (optional, default=True)
replot (bool): replot the widget (optional, default=True)
"""
item.setVisible(state)
if item is self.active_item and not state:
self.set_active_item(None) # Notify the item list (see baseplot)
if notify:
self.SIG_ITEMS_CHANGED.emit(self)
if replot:
self.replot()
def __set_items_visible(
self,
state: bool,
items: list[itf.IBasePlotItem] | None = None,
item_type: itf.IBasePlotItem | None = None,
) -> None:
"""Show/hide items (if *items* is None, show/hide all items)
Args:
state (bool): the visibility state
items (list[IBasePlotItem]): the items (optional, default=None)
item_type (IBasePlotItem): the item type (optional, default=None)
"""
if items is None:
items = self.get_items(item_type=item_type)
for item in items:
self.set_item_visible(item, state, notify=False, replot=False)
self.SIG_ITEMS_CHANGED.emit(self)
self.replot()
[docs]
def show_items(
self,
items: list[itf.IBasePlotItem] | None = None,
item_type: itf.IBasePlotItem | None = None,
) -> None:
"""Show items (if *items* is None, show all items)
Args:
items (list[IBasePlotItem]): the items (optional, default=None)
item_type (IBasePlotItem): the item type (optional, default=None)
"""
self.__set_items_visible(True, items, item_type=item_type)
[docs]
def hide_items(
self,
items: list[itf.IBasePlotItem] | None = None,
item_type: itf.IBasePlotItem | None = None,
) -> None:
"""Hide items (if *items* is None, hide all items)
Args:
items (list[IBasePlotItem]): the items (optional, default=None)
item_type (IBasePlotItem): the item type (optional, default=None)
"""
self.__set_items_visible(False, items, item_type=item_type)
[docs]
def save_items(self, iofile: str | IO[str], selected: bool = False) -> None:
"""
Save (serializable) items to file using the :py:mod:`pickle` protocol
Args:
iofile (str | IO[str]): the file object or filename
selected (bool): if True, will save only selected items
(optional, default=False)
See also :py:meth:`.BasePlot.restore_items`
"""
if selected:
items = self.get_selected_items()
else:
items = self.items[:]
items = [item for item in items if itf.ISerializableType in item.types()]
pickle.dump(items, iofile)
[docs]
def restore_items(self, iofile: str | IO[str]) -> None:
"""
Restore items from file using the :py:mod:`pickle` protocol
Args:
iofile (str | IO[str]): the file object or filename
See also :py:meth:`.BasePlot.save_items`
"""
items = pickle.load(iofile)
for item in items:
self.add_item(item)
[docs]
def serialize(
self,
writer: guidata.io.HDF5Writer | guidata.io.INIWriter | guidata.io.JSONWriter,
) -> None:
"""Serialize object to HDF5 writer
Args:
writer: HDF5, INI or JSON writer
See also :py:meth:`.BasePlot.restore_items`
"""
items = [item for item in self.items if itf.ISerializableType in item.types()]
io.save_items(writer, items)
[docs]
def deserialize(
self,
reader: guidata.io.HDF5Reader | guidata.io.INIReader | guidata.io.JSONReader,
) -> None:
"""Deserialize object from HDF5 reader
Args:
reader: HDF5, INI or JSON reader
See also :py:meth:`.BasePlot.save_items`
"""
for item in io.load_items(reader):
self.add_item(item)
[docs]
def set_items(self, *args: itf.IBasePlotItem) -> None:
"""Utility function used to quickly setup a plot
with a set of items
Args:
args: the items
Example:
self.set_items(item1, item2, item3)
"""
self.del_all_items()
for item in args:
self.add_item(item)
[docs]
def del_all_items(self, except_grid: bool = True) -> None:
"""Del all items, eventually (default) except grid
Args:
except_grid (bool): if True, don't delete grid (optional, default=True)
"""
items = [
item for item in self.items if not except_grid or item is not self.grid
]
self.del_items(items)
def __swap_items_z(self, item1: qwt.QwtPlotItem, item2: qwt.QwtPlotItem) -> None:
"""Swap items z-order
Args:
item1 (QwtPlotItem): the first item
item2 (QwtPlotItem): the second item
"""
old_item1_z, old_item2_z = item1.z(), item2.z()
item1.setZ(max([_it.z() for _it in self.items]) + 1)
item2.setZ(old_item1_z)
item1.setZ(old_item2_z)
[docs]
def move_up(self, item_list: list[itf.IBasePlotItem] | itf.IBasePlotItem) -> bool:
"""Move item(s) up, i.e. to the foreground
(swap item with the next item in z-order)
Args:
item_list (list[IBasePlotItem] | IBasePlotItem): the item(s)
Returns:
bool: True if items have been moved effectively
"""
objects = self.get_items(z_sorted=True)
items = sorted(list(item_list), reverse=True, key=lambda x: objects.index(x))
changed = False
if objects.index(items[-1]) > 0:
for item in items:
index = objects.index(item)
self.__swap_items_z(item, objects[index - 1])
changed = True
if changed:
self.SIG_ITEMS_CHANGED.emit(self)
return changed
[docs]
def move_down(self, item_list: list[itf.IBasePlotItem] | itf.IBasePlotItem) -> bool:
"""Move item(s) down, i.e. to the background
(swap item with the previous item in z-order)
Args:
item_list (list[IBasePlotItem] | IBasePlotItem): the item(s)
Returns:
bool: True if items have been moved effectively
"""
objects = self.get_items(z_sorted=True)
items = sorted(list(item_list), reverse=False, key=lambda x: objects.index(x))
changed = False
if objects.index(items[-1]) < len(objects) - 1:
for item in items:
index = objects.index(item)
self.__swap_items_z(item, objects[index + 1])
changed = True
if changed:
self.SIG_ITEMS_CHANGED.emit(self)
return changed
[docs]
def set_items_readonly(self, state: bool) -> None:
"""Set all items readonly state to *state*
Default item's readonly state: False (items may be deleted)
Args:
state (bool): the readonly state
"""
for item in self.get_items():
item.set_readonly(state)
self.SIG_ITEMS_CHANGED.emit(self)
[docs]
def select_item(self, item: itf.IBasePlotItem) -> None:
"""Select item
Args:
item (IBasePlotItem): the item
"""
item.select()
for itype in item.types():
self.last_selected[itype] = item
self.SIG_ITEM_SELECTION_CHANGED.emit(self)
[docs]
def unselect_item(self, item: itf.IBasePlotItem) -> None:
"""Unselect item
Args:
item (IBasePlotItem): the item
"""
item.unselect()
self.SIG_ITEM_SELECTION_CHANGED.emit(self)
[docs]
def get_last_active_item(
self, item_type: type[itf.IItemType]
) -> itf.IBasePlotItem | None:
"""Return last active item corresponding to passed `item_type`
Args:
item_type (IItemType): the item type
Returns:
IBasePlotItem: the item
"""
assert issubclass(item_type, itf.IItemType)
return self.last_selected.get(item_type)
[docs]
def select_all(self) -> None:
"""Select all selectable items"""
last_item = None
block = self.blockSignals(True)
for item in self.items:
if item.can_select():
self.select_item(item)
last_item = item
self.blockSignals(block)
self.SIG_ITEM_SELECTION_CHANGED.emit(self)
self.set_active_item(last_item)
[docs]
def unselect_all(self) -> None:
"""Unselect all selected items"""
for item in self.items:
if item.can_select():
item.unselect()
self.set_active_item(None)
self.SIG_ITEM_SELECTION_CHANGED.emit(self)
[docs]
def select_some_items(self, items: list[itf.IBasePlotItem]) -> None:
"""Select items
Args:
items (list[IBasePlotItem]): the items
"""
active = self.active_item
block = self.blockSignals(True)
self.unselect_all()
if items:
new_active_item = items[-1]
else:
new_active_item = None
for item in items:
self.select_item(item)
if active is item:
new_active_item = item
self.set_active_item(new_active_item)
self.blockSignals(block)
if new_active_item is not active:
# if the new selection doesn't include the
# previously active item
self.SIG_ACTIVE_ITEM_CHANGED.emit(self)
self.SIG_ITEM_SELECTION_CHANGED.emit(self)
[docs]
def set_active_item(self, item: itf.IBasePlotItem, select: bool = True) -> None:
"""Set active item, and unselect the old active item. For CurveItems,
the grid axes are changed according to the selected item
Args:
item: the item
select: select item after setting it as active (optional, default=True)
"""
old_active = self.active_item
self.active_item = item
if self.active_item is not None:
if not item.selected:
self.select_item(self.active_item)
self._active_xaxis = item.xAxis()
self._active_yaxis = item.yAxis()
self.SIG_ACTIVE_ITEM_CHANGED.emit(self)
if item is not None and old_active is not item:
if isinstance(item, CurveItem):
self.grid.setAxes(item.xAxis(), item.yAxis())
elif isinstance(item, BaseImageItem):
self.update_colormap_axis(item)
if not select and item is not None:
self.unselect_item(item)
[docs]
def get_active_axes(self) -> tuple[int, int]:
"""Return active axes
Returns:
tuple[int, int]: the active axes IDs
"""
item = self.active_item
if item is not None:
self._active_xaxis = item.xAxis()
self._active_yaxis = item.yAxis()
return self._active_xaxis, self._active_yaxis
[docs]
def get_active_item(self, force: bool = False) -> itf.IBasePlotItem | None:
"""
Return active item
Force item activation if there is no active item
Args:
force (bool): force item activation (optional, default=False)
Returns:
IBasePlotItem: the active item or None
"""
if force and not self.active_item:
for item in self.get_items():
if item.can_select():
self.set_active_item(item)
break
return self.active_item
[docs]
def get_nearest_object(
self, pos: QC.QPointF, close_dist: int = 0
) -> tuple[itf.IBasePlotItem | None, float, int | None, bool]:
"""
Return nearest item from position 'pos'
Args:
pos (QPointF): the position
close_dist (int): the distance (optional, default=0)
Returns:
tuple[IBasePlotItem | None, float, int | None, bool]: the nearest item
If close_dist > 0:
Return the first found item (higher z) which distance to 'pos' is
less than close_dist
else:
Return the closest item
"""
selobj, distance, inside, handle = None, sys.maxsize, None, None
for obj in self.get_items(z_sorted=True):
if not obj.isVisible() or not obj.can_select():
continue
d, _handle, _inside, other = obj.hit_test(pos)
if d < distance:
selobj, distance, handle, inside = obj, d, _handle, _inside
if d < close_dist:
break
if other is not None:
# e.g. LegendBoxItem: selecting a curve ('other') instead of
# legend box ('obj')
return other, 0, None, True
return selobj, distance, handle, inside
[docs]
def get_nearest_object_in_z(
self, pos: QC.QPointF
) -> tuple[itf.IBasePlotItem | None, int, bool | None, int | None]:
"""
Return nearest item for which position 'pos' is inside of it
(iterate over items with respect to their 'z' coordinate)
Args:
pos (QPointF): the position
Returns:
tuple[IBasePlotItem | None, int, bool | None, int | None]: the nearest item
"""
selobj, distance, inside, handle = None, sys.maxsize, None, None
for obj in self.get_items(z_sorted=True):
if not obj.isVisible() or not obj.can_select():
continue
d, _handle, _inside, _other = obj.hit_test(pos)
if _inside:
selobj, distance, handle, inside = obj, d, _handle, _inside
break
return selobj, distance, handle, inside
[docs]
def get_plot_parameters_status(self, key: str) -> bool:
"""
Return True if the plot parameters are available
Args:
key (str): the parameter key
Returns:
bool: True if the plot parameters are available
"""
if key == "item":
return self.get_active_item() is not None
else:
return True
[docs]
def get_selected_item_parameters(self, itemparams: ItemParameters) -> None:
"""
Return a list of DataSets for selected items parameters
the datasets will be edited and passed back to set_plot_parameters
Args:
itemparams (ItemParameters): the item parameters
"""
for item in self.get_selected_items():
item.get_item_parameters(itemparams)
# Retrieving active_item's parameters after every other item:
# this way, the common datasets will be based on its parameters
active_item = self.get_active_item()
active_item.get_item_parameters(itemparams)
[docs]
def get_axesparam_class(self, item: itf.IBasePlotItem) -> AxesParam:
"""Return AxesParam dataset class associated to item's type
Args:
item (IBasePlotItem): the item
Returns:
AxesParam: the AxesParam dataset class
"""
if isinstance(item, BaseImageItem):
return ImageAxesParam
else:
return AxesParam
[docs]
def get_plot_parameters(self, key: str, itemparams: ItemParameters) -> None:
"""
Return a list of DataSets for a given parameter key
the datasets will be edited and passed back to set_plot_parameters
this is a generic interface to help building context menus
using the BasePlotMenuTool
Args:
key (str): the parameter key
itemparams (ItemParameters): the item parameters
"""
if key == "axes":
for i, axeparam in enumerate(self.axes_styles):
itemparams.add(f"AxeStyleParam{i}", self, axeparam)
elif key == "grid":
self.grid.gridparam.update_param(self.grid)
itemparams.add("GridParam", self, self.grid.gridparam)
elif key == "item":
active_item = self.get_active_item()
if not active_item:
return
self.get_selected_item_parameters(itemparams)
if self.get_show_axes_tab():
Param = self.get_axesparam_class(active_item)
axesparam = Param(
title=_("Axes"),
icon="lin_lin.png",
comment=_("Axes associated to selected item"),
)
axesparam.update_param(active_item)
itemparams.add("AxesParam", self, axesparam)
[docs]
def set_item_parameters(self, itemparams: ItemParameters) -> None:
"""Set item (plot, here) parameters
Args:
itemparams (ItemParameters): the item parameters
"""
# Grid style
dataset = itemparams.get("GridParam")
if dataset is not None:
dataset.update_grid(self.grid)
self.grid.gridparam = dataset
# Axe styles
datasets = [itemparams.get(f"AxeStyleParam{i}") for i in range(4)]
if datasets[0] is not None:
self.axes_styles = datasets
self.update_all_axes_styles()
# Changing active item's associated axes
dataset = itemparams.get("AxesParam")
if dataset is not None:
active_item = self.get_active_item()
if active_item is not None:
# active_item may be None when dealing with non-selectable items only
dataset.update_item(active_item)
[docs]
def edit_plot_parameters(self, key: str) -> None:
"""
Edit plot parameters
Args:
key (str): the parameter key
"""
multiselection = len(self.get_selected_items()) > 1
itemparams = ItemParameters(multiselection=multiselection)
self.get_plot_parameters(key, itemparams)
title, icon = PARAMETERS_TITLE_ICON[key]
itemparams.edit(self, title, icon)
[docs]
def edit_axis_parameters(self, axis_id: int) -> None:
"""Edit axis parameters
Args:
axis_id (int): the axis ID
"""
if axis_id != self.colormap_axis:
if axis_id in (cst.Y_LEFT, cst.Y_RIGHT):
title = _("Y Axis")
else:
title = _("X Axis")
param = AxisParam(title=title)
param.update_param(self, axis_id)
if param.edit(parent=self):
param.update_axis(self, axis_id)
self.replot()
[docs]
def add_autoscale_types(self, item_types: tuple[type]) -> None:
"""
Add item types to autoscale list
Args:
item_types (tuple[type]): the item types
"""
self.AUTOSCALE_TYPES += item_types
[docs]
def add_autoscale_excludes(self, items: list[itf.IBasePlotItem]) -> None:
"""Add items to autoscale excludes list
Args:
items (list[IBasePlotItem]): the items
"""
current_list = self.get_auto_scale_excludes()
for item in items:
if item not in current_list:
self.__autoscale_excluded_items.append(weakref.ref(item))
[docs]
def remove_autoscale_excludes(self, items: list[itf.IBasePlotItem]) -> None:
"""Remove items from autoscale excludes list
Args:
items (list[IBasePlotItem]): the items
"""
current_list = self.get_auto_scale_excludes()
for item in items:
if item in current_list:
self.__autoscale_excluded_items.remove(weakref.ref(item))
[docs]
def get_auto_scale_excludes(self) -> list[itf.IBasePlotItem]:
"""Return autoscale excludes
Returns:
list[IBasePlotItem]: the items
"""
# Update the list of excluded items, removing the items that have been
# deleted since the last call to this method
self.__autoscale_excluded_items = [
item_ref
for item_ref in self.__autoscale_excluded_items
if item_ref() is not None
]
return [item_ref() for item_ref in self.__autoscale_excluded_items]
[docs]
def get_axis_autoscale_strategy(
self, axis_id: int
) -> tuple[str, float | None, float | None]:
"""Return the autoscale strategy configured for a given axis.
Args:
axis_id: the axis ID
Returns:
A 3-tuple ``(strategy, vmin, vmax)`` where ``strategy`` is one of
``"auto"``, ``"fixed"`` or ``"none"``. ``vmin``/``vmax`` are the
user-defined bounds applied when ``strategy == "fixed"``
(``None`` otherwise).
"""
return self._axis_autoscale_strategy.get(axis_id, ("auto", None, None))
[docs]
def set_axis_autoscale_strategy(
self,
axis_id: int,
strategy: str,
vmin: float | None = None,
vmax: float | None = None,
) -> None:
"""Set the autoscale strategy for a given axis.
Args:
axis_id: the axis ID
strategy: one of ``"auto"`` (compute bounds from items, current
behavior), ``"fixed"`` (apply ``vmin``/``vmax``) or ``"none"``
(leave the axis untouched on autoscale)
vmin: lower bound applied when ``strategy == "fixed"``
vmax: upper bound applied when ``strategy == "fixed"``
"""
if strategy not in ("auto", "fixed", "none"):
raise ValueError(
f"Invalid autoscale strategy {strategy!r}: "
"expected one of 'auto', 'fixed', 'none'"
)
self._axis_autoscale_strategy[axis_id] = (strategy, vmin, vmax)
[docs]
def do_autoscale(self, replot: bool = True, axis_id: int | None = None) -> None:
"""Do autoscale on all axes
The behavior of each axis depends on its autoscale strategy
(see :py:meth:`set_axis_autoscale_strategy`): ``"auto"`` computes
bounds from items (default), ``"fixed"`` applies the configured
``vmin``/``vmax`` and ``"none"`` leaves the axis untouched.
Args:
replot (bool): replot the widget (optional, default=True)
axis_id (int | None): the axis ID (optional, default=None)
"""
auto = self.autoReplot()
self.setAutoReplot(False)
# TODO: Implement the case when axes are synchronised
for axis_id in self.AXIS_IDS if axis_id is None else [axis_id]:
vmin, vmax = None, None
if not self.axisEnabled(axis_id):
continue
strategy, fixed_vmin, fixed_vmax = self.get_axis_autoscale_strategy(axis_id)
if strategy == "none":
continue
if strategy == "fixed":
if fixed_vmin is None or fixed_vmax is None:
continue
self.set_axis_limits(axis_id, fixed_vmin, fixed_vmax)
continue
for item in self.get_items():
if (
isinstance(item, self.AUTOSCALE_TYPES)
and not item.is_empty()
and item.isVisible()
and item not in self.get_auto_scale_excludes()
):
bounds = item.boundingRect()
if axis_id == item.xAxis():
xmin, xmax = bounds.left(), bounds.right()
if vmin is None or xmin < vmin:
vmin = xmin
if vmax is None or xmax > vmax:
vmax = xmax
elif axis_id == item.yAxis():
ymin, ymax = bounds.top(), bounds.bottom()
if vmin is None or ymin < vmin:
vmin = ymin
if vmax is None or ymax > vmax:
vmax = ymax
if vmin is None or vmax is None:
continue
if vmin == vmax: # same behavior as MATLAB
vmin -= 1
vmax += 1
elif self.get_axis_scale(axis_id) == "lin":
dv = vmax - vmin
margin = self.autoscale_margin_percent / 100.0
vmin -= margin * dv
vmax += margin * dv
elif vmin > 0 and vmax > 0: # log scale
dv = np.log10(vmax) - np.log10(vmin)
margin = self.autoscale_margin_percent / 100.0
vmin = 10 ** (np.log10(vmin) - margin * dv)
vmax = 10 ** (np.log10(vmax) + margin * dv)
self.set_axis_limits(axis_id, vmin, vmax)
self.setAutoReplot(auto)
self.updateAxes()
if self.lock_aspect_ratio:
self.apply_aspect_ratio(full_scale=True)
if replot:
self.replot()
self.SIG_PLOT_AXIS_CHANGED.emit(self)
[docs]
def disable_autoscale(self) -> None:
"""Re-apply the axis scales so as to disable autoscaling
without changing the view"""
for axis_id in self.AXIS_IDS:
vmin, vmax = self.get_axis_limits(axis_id)
self.set_axis_limits(axis_id, vmin, vmax)
[docs]
def invalidate(self) -> None:
"""Invalidate paint cache and schedule redraw
use instead of replot when only the content
of the canvas needs redrawing (axes, shouldn't change)
"""
self.canvas().replot()
self.update()
[docs]
def get_axis_direction(self, axis_id: int | str) -> bool:
"""
Return axis direction of increasing values
Args:
axis_id: axis id (constants.Y_LEFT, constants.X_BOTTOM, ...)
or string: 'bottom', 'left', 'top' or 'right'
Returns:
False (default)
"""
axis_id = self.get_axis_id(axis_id)
return self.axes_reverse[axis_id]
[docs]
def set_axis_direction(self, axis_id: int | str, reverse: bool = False) -> None:
"""
Set axis direction of increasing values
Args:
axis_id: axis id (constants.Y_LEFT, constants.X_BOTTOM, ...)
or string: 'bottom', 'left', 'top' or 'right'
reverse (bool): False (default)
If reverse is False:
- x-axis values increase from left to right
- y-axis values increase from bottom to top
If reverse is True:
- x-axis values increase from right to left
- y-axis values increase from top to bottom
"""
axis_id = self.get_axis_id(axis_id)
if reverse != self.axes_reverse[axis_id]:
self.replot()
self.axes_reverse[axis_id] = reverse
axis_map = self.canvasMap(axis_id)
self.setAxisScale(axis_id, axis_map.s2(), axis_map.s1())
self.updateAxes()
self.SIG_AXIS_DIRECTION_CHANGED.emit(self, axis_id)
[docs]
def set_titles(
self,
title: str | None = None,
xlabel: str | tuple[str, str] | None = None,
ylabel: str | tuple[str, str] | None = None,
xunit: str | tuple[str, str] | None = None,
yunit: str | tuple[str, str] | None = None,
) -> None:
"""
Set plot and axes titles at once
Args:
title (str): plot title
xlabel (str | tuple[str, str]): (bottom axis title, top axis title)
or bottom axis title only
ylabel (str | tuple[str, str]): (left axis title, right axis title)
or left axis title only
xunit (str | tuple[str, str]): (bottom axis unit, top axis unit)
or bottom axis unit only
yunit (str | tuple[str, str]): (left axis unit, right axis unit)
or left axis unit only
"""
if title is not None:
self.set_title(title)
if xlabel is not None:
if isinstance(xlabel, str):
xlabel = (xlabel, "")
for label, axis in zip(xlabel, ("bottom", "top")):
if label is not None:
self.set_axis_title(axis, label)
if ylabel is not None:
if isinstance(ylabel, str):
ylabel = (ylabel, "")
for label, axis in zip(ylabel, ("left", "right")):
if label is not None:
self.set_axis_title(axis, label)
if xunit is not None:
if isinstance(xunit, str):
xunit = (xunit, "")
for unit, axis in zip(xunit, ("bottom", "top")):
if unit is not None:
self.set_axis_unit(axis, unit)
if yunit is not None:
if isinstance(yunit, str):
yunit = (yunit, "")
for unit, axis in zip(yunit, ("left", "right")):
if unit is not None:
self.set_axis_unit(axis, unit)
[docs]
def set_pointer(self, pointer_type: str | None) -> None:
"""
Set pointer.
Args:
pointer_type (str): pointer type (None, 'canvas', 'curve')
Meaning of pointer_type:
* None: disable pointer
* 'canvas': enable canvas pointer
* 'curve': enable on-curve pointer
"""
self.canvas_pointer = False
self.curve_pointer = False
if pointer_type == "canvas":
self.canvas_pointer = True
elif pointer_type == "curve":
self.curve_pointer = True
[docs]
def set_antialiasing(self, checked: bool) -> None:
"""Toggle curve antialiasing
Args:
checked (bool): True to enable antialiasing
"""
self.antialiased = checked
for curve in self.itemList():
if isinstance(curve, qwt.QwtPlotCurve):
curve.setRenderHint(qwt.QwtPlotItem.RenderAntialiased, self.antialiased)
[docs]
def set_plot_limits(
self,
x0: float,
x1: float,
y0: float,
y1: float,
xaxis: str | int = "bottom",
yaxis: str | int = "left",
) -> None:
"""Set plot scale limits
Args:
x0 (float): x min
x1 (float): x max
y0 (float): y min
y1 (float): y max
xaxis (str | int): x axis name or id (optional, default='bottom')
yaxis (str | int): y axis name or id (optional, default='left')
"""
self.set_axis_limits(yaxis, y0, y1)
self.set_axis_limits(xaxis, x0, x1)
self.updateAxes()
self.SIG_AXIS_DIRECTION_CHANGED.emit(self, self.get_axis_id(yaxis))
self.SIG_AXIS_DIRECTION_CHANGED.emit(self, self.get_axis_id(xaxis))
[docs]
def get_plot_limits(
self, xaxis: str | int = "bottom", yaxis: str | int = "left"
) -> tuple[float, float, float, float]:
"""Return plot scale limits
Args:
xaxis (str | int): x axis name or id (optional, default='bottom')
yaxis (str | int): y axis name or id (optional, default='left')
Returns:
tuple[float, float, float, float]: x0, x1, y0, y1
"""
x0, x1 = self.get_axis_limits(xaxis)
y0, y1 = self.get_axis_limits(yaxis)
return x0, x1, y0, y1
# ---- Image scale/aspect ratio -related API -------------------------------
[docs]
def get_current_aspect_ratio(self) -> float | None:
"""Return current aspect ratio
Returns:
The current aspect ratio or None if the aspect ratio cannot be computed
(this happens when the plot has been shrunk to a size so that the
width is zero)
"""
dx = self.axisScaleDiv(cst.X_BOTTOM).range()
dy = self.axisScaleDiv(cst.Y_LEFT).range()
h = self.canvasMap(cst.Y_LEFT).pDist()
w = self.canvasMap(cst.X_BOTTOM).pDist()
try:
return fabs((h * dx) / (w * dy))
except ZeroDivisionError:
return None
[docs]
def get_aspect_ratio(self) -> float:
"""Return aspect ratio
Returns:
float: the aspect ratio
"""
return self.__aspect_ratio
[docs]
def set_aspect_ratio(
self, ratio: float | None = None, lock: bool | None = None
) -> None:
"""Set aspect ratio
Args:
ratio (float): the aspect ratio (optional, default=None)
lock (bool): lock aspect ratio (optional, default=None)
"""
if ratio is not None:
self.__aspect_ratio = ratio
if lock is not None:
self.lock_aspect_ratio = lock
self.apply_aspect_ratio()
[docs]
def apply_aspect_ratio(self, full_scale: bool = False) -> None:
"""
Apply aspect ratio
Args:
full_scale: if True, the aspect ratio is applied to the full scale
(this argument is True only when doing autoscale, i.e. in method
:py:meth:`do_autoscale`: it is necessary to ensure that the whole
image items are visible after autoscale, whatever their aspect ratio)
"""
if not self.isVisible():
return
current_aspect = self.get_current_aspect_ratio()
if current_aspect is None or (
abs(current_aspect - self.__aspect_ratio) < self.EPSILON_ASPECT_RATIO
):
return
ymap = self.canvasMap(cst.Y_LEFT)
xmap = self.canvasMap(cst.X_BOTTOM)
h = ymap.pDist()
w = xmap.pDist()
dx1, dy1 = xmap.sDist(), fabs(ymap.sDist())
x0, y0 = xmap.s1(), ymap.s1()
x1, y1 = xmap.s2(), ymap.s2()
if x0 > x1:
x0, x1 = x1, x0
if y0 > y1:
y0, y1 = y1, y0
if w == 0 or h == 0:
return # avoid division by zero
# Compute new Y limits, keeping the same X limits and aspect ratio
dy2 = (h * dx1) / (w * self.__aspect_ratio)
# Compute new X limits, keeping the same Y limits and aspect ratio
dx2 = (w * dy1 * self.__aspect_ratio) / h
if full_scale and dy2 <= dy1:
# If the new Y limits are smaller than the current ones,
# *and* if we are doing autoscale, we keep the current Y limits.
# Why? Because, if the new Y limits are smaller than the current ones,
# it means that the image items would not be entirely visible after
# autoscale, and we don't want that.
delta_x = 0.5 * (dx2 - dx1)
x0 -= delta_x
x1 += delta_x
else:
# If we are not doing autoscale, then we only change the Y limits (that
# is a choice, we could also change the X limits).
# If we are doing autoscale, then we change the Y limits only if the
# new Y limits are larger than the current ones, in order to ensure
# that the image items are entirely visible after autoscale.
delta_y = 0.5 * (dy2 - dy1)
y0 -= delta_y
y1 += delta_y
self.set_plot_limits(x0, x1, y0, y1)
# ---- LUT/colormap-related API --------------------------------------------
[docs]
def notify_colormap_changed(self) -> None:
"""Levels histogram range has changed"""
item = self.get_last_active_item(itf.IColormapImageItemType)
if item is not None:
self.update_colormap_axis(item)
self.replot()
self.SIG_LUT_CHANGED.emit(self)
self.SIG_ACTIVE_ITEM_CHANGED.emit(self)
[docs]
def update_colormap_axis(self, item: itf.IColormapImageItemType) -> None:
"""
Update colormap axis
Args:
item (IColormapImageItemType): the item
"""
if itf.IColormapImageItemType not in item.types():
return
zaxis = self.colormap_axis
axiswidget: QwtScaleWidget = self.axisWidget(zaxis)
self.setAxisScale(zaxis, item.min, item.max)
# XXX: The colormap can't be displayed if min>max, to fix this
# we should pass an inverted colormap along with _max, _min values
axiswidget.setColorMap(
qwt.QwtInterval(item.min, item.max), item.get_color_map()
)
self.updateAxes()
# # Keep this around to debug too many replots
# def replot(self):
# import traceback
# traceback.print_stack()
# qwt.QwtPlot.replot(self)
[docs]
@classmethod
def register_autoscale_type(cls, type_):
"""Add *type_* to the list of types used to check if an item is
taken into account to compute the scale.
Class must defines the following methods:
* is_empty(): returns True if the item is empty
* boundingRect(): returns the bouding rect as a QRectF
* isVisible(): returns True if item is visible
* xAxis() and yAxis()
"""
cls.AUTOSCALE_TYPES += (type_,)
# Register PolygonShape and annotated shape classes.
# It's not possible to simply register AnnotatedShape class
# because their is no warranty that SHAPE_CLASS implements all methods.
BasePlot.register_autoscale_type(PolygonShape)
BasePlot.register_autoscale_type(AnnotatedRectangle)
BasePlot.register_autoscale_type(AnnotatedCircle)
BasePlot.register_autoscale_type(AnnotatedEllipse)
BasePlot.register_autoscale_type(AnnotatedObliqueRectangle)
BasePlot.register_autoscale_type(AnnotatedSegment)
BasePlot.register_autoscale_type(AnnotatedPoint)
BasePlot.register_autoscale_type(AnnotatedPolygon)