Source code for plotpy.plot.base

# -*- 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 format_coordinate_values( self, x: float, y: float, xaxis: str | int, yaxis: str | int, title: str ) -> str: """Format coordinate values with axis-aware formatting Args: x: The x coordinate value y: The y coordinate value xaxis: The x axis name ("bottom", "top") or axis ID yaxis: The y axis name ("left", "right") or axis ID title: The title to display in the coordinate string Returns: str: Formatted coordinate string with HTML markup """ x_formatted = self.format_coordinate_value(x, xaxis) y_formatted = self.format_coordinate_value(y, yaxis) return f"<b>{title}</b><br>x = {x_formatted}<br>y = {y_formatted}"
[docs] def format_coordinate_value(self, value: float, axis_id: str | int) -> str: """Format a coordinate value based on the axis scale type Args: value: The coordinate value to format axis_id: The axis name ("bottom", "top", "left", "right") or axis ID Returns: str: Formatted coordinate value """ axis_id = self.get_axis_id(axis_id) # Check if this axis is using datetime scale if self.get_axis_scale(axis_id) == "datetime": try: scale_draw: qwt.QwtDateTimeScaleDraw = self.axisScaleDraw(axis_id) dt = datetime.fromtimestamp(value) return dt.strftime(scale_draw.get_format()) except (ValueError, OSError, OverflowError): # Handle invalid timestamps, fall back to numeric display return f"{value:g}" else: # Standard numeric formatting return f"{value:g}"
[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 save_widget(self, fname: str) -> None: """Grab widget's window and save it to filename (/*.png, /*.pdf) Args: fname (str): the filename """ fname = str(fname) if fname.lower().endswith(".pdf"): printer = QPrinter() printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat) printer.setPageOrientation(QG.QPageLayout.Landscape) printer.setOutputFileName(fname) printer.setCreator("plotpy") self.print_(printer) elif fname.lower().endswith(".png"): pixmap = self.grab() pixmap.save(fname, "PNG") else: raise RuntimeError(_("Unknown file extension"))
[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_context_menu(self) -> QW.QMenu: """Return widget context menu Returns: QMenu: the context menu """ return self.manager.get_context_menu(self)
[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)