Source code for plotpy.items.shape.marker

# -*- coding: utf-8 -*-

from __future__ import annotations

import math
from contextlib import contextmanager
from typing import TYPE_CHECKING, Generator

from guidata.dataset import update_dataset
from guidata.utils.misc import assert_interfaces_valid
from qtpy import QtCore as QC
from qwt import QwtPlotMarker

from plotpy.config import CONF, _
from plotpy.coords import canvas_to_axes
from plotpy.interfaces import IBasePlotItem, IShapeItemType
from plotpy.styles.base import MARKERSTYLES
from plotpy.styles.shape import MarkerParam

if TYPE_CHECKING:
    from collections.abc import Callable

    import guidata.io
    import qwt.scale_map
    from qtpy.QtCore import QPointF, QRectF
    from qtpy.QtGui import QPainter

    from plotpy.interfaces import IItemType
    from plotpy.plot import BasePlot
    from plotpy.styles.base import ItemParameters


@contextmanager
def no_symbol_context(
    item: QwtPlotMarker, condition: bool
) -> Generator[None, None, None]:
    """Context manager for temporarily changing a marker's symbol

    Args:
        item: The marker object whose symbol might be changed
        condition: If True, the symbol will be changed to None temporarily
    """
    old_symbol = None
    if condition:
        old_symbol = item.symbol()
        item.setSymbol(None)
    try:
        yield
    finally:
        if condition and old_symbol is not None:
            item.setSymbol(old_symbol)


[docs] class Marker(QwtPlotMarker): """Marker shape Args: label_cb: Label callback (must return a string, takes x and y as arguments) constraint_cb: Constraint callback (must return a tuple (x, y), takes x and y as arguments) markerparam: Marker parameters .. note:: Marker class derives from QwtPlotMarker, which is a QwtPlotItem. That is why AbstractShape methods are re-implemented here. """ __implements__ = (IBasePlotItem,) _readonly = False _private = False _can_select = True _can_resize = True _can_rotate = False _can_move = True _icon_name = "marker.png" def __init__( self, label_cb: Callable | None = None, constraint_cb: Callable | None = None, markerparam: MarkerParam = None, ) -> None: super().__init__() self._pending_center_handle = None self.selected = False self.label_cb = label_cb if constraint_cb is None: constraint_cb = self.center_handle self.constraint_cb = constraint_cb if markerparam is None: self.markerparam = MarkerParam(_("Marker")) self.markerparam.read_config(CONF, "plot", "marker/cursor") else: self.markerparam = markerparam self.markerparam.update_item(self) def __reduce__(self) -> tuple[type, tuple, tuple]: """Return state information for pickling""" self.markerparam.update_param(self) state = (self.markerparam, self.xValue(), self.yValue(), self.z()) return (Marker, (), state) def __setstate__(self, state: tuple) -> None: """Restore state information from pickled state""" self.markerparam, xvalue, yvalue, z = state self.setXValue(xvalue) self.setYValue(yvalue) self.setZ(z) self.markerparam.update_item(self)
[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 """ self.markerparam.update_param(self) writer.write(self.markerparam, group_name="markerparam") writer.write(self.xValue(), group_name="x") writer.write(self.yValue(), group_name="y") writer.write(self.z(), group_name="z")
[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 """ self.markerparam = MarkerParam(_("Marker"), icon="marker.png") reader.read("markerparam", instance=self.markerparam) self.markerparam.update_item(self) self.setXValue(reader.read("x")) self.setYValue(reader.read("y")) self.setZ(reader.read("z"))
# ------QwtPlotItem API------------------------------------------------------
[docs] def draw( self, painter: QPainter, xMap: qwt.scale_map.QwtScaleMap, yMap: qwt.scale_map.QwtScaleMap, canvasRect: QRectF, ) -> None: """Draw the item Args: painter: Painter xMap: X axis scale map yMap: Y axis scale map canvasRect: Canvas rectangle """ if self._pending_center_handle: x, y = self.center_handle(self.xValue(), self.yValue()) self.setValue(x, y) self.update_label() with no_symbol_context(self, not self.can_resize()): QwtPlotMarker.draw(self, painter, xMap, yMap, canvasRect)
# ------IBasePlotItem API----------------------------------------------------
[docs] def get_icon_name(self) -> str: """Return the icon name Returns: Icon name """ return self._icon_name
[docs] def set_icon_name(self, icon_name: str) -> None: """Set the icon name Args: icon_name: Icon name """ self._icon_name = icon_name
[docs] def set_selectable(self, state: bool) -> None: """Set item selectable state Args: state: True if item is selectable, False otherwise """ self._can_select = state
[docs] def set_resizable(self, state: bool) -> None: """Set item resizable state (or any action triggered when moving an handle, e.g. rotation) Args: state: True if item is resizable, False otherwise """ self._can_resize = state
[docs] def set_movable(self, state: bool) -> None: """Set item movable state Args: state: True if item is movable, False otherwise """ self._can_move = state # For a marker, setting the movable state to False means that, obviously, # it cannot be moved. However, due to the way the event system works, # the marker can still be moved by the user: it has to be not resizable # to prevent the user from moving it by dragging the handles, which is not # intuitive for a marker (from a user point of view, a marker is a point # or a line, not a shape with handles). # So, we set the resizable state to the same value as the movable state: self.set_resizable(state)
[docs] def set_rotatable(self, state: bool) -> None: """Set item rotatable state Args: state: True if item is rotatable, False otherwise """ self._can_rotate = state
[docs] def can_select(self) -> bool: """ Returns True if this item can be selected Returns: bool: True if item can be selected, False otherwise """ return self._can_select
[docs] def can_resize(self) -> bool: """ Returns True if this item can be resized Returns: bool: True if item can be resized, False otherwise """ return self._can_resize
[docs] def can_rotate(self) -> bool: """ Returns True if this item can be rotated Returns: bool: True if item can be rotated, False otherwise """ return self._can_rotate
[docs] def can_move(self) -> bool: """ Returns True if this item can be moved Returns: bool: True if item can be moved, False otherwise """ return self._can_move
[docs] def types(self) -> tuple[type[IItemType], ...]: """Returns a group or category for this item. This should be a tuple of class objects inheriting from IItemType Returns: tuple: Tuple of class objects inheriting from IItemType """ return (IShapeItemType,)
[docs] def set_readonly(self, state: bool) -> None: """Set object readonly state Args: state: True if object is readonly, False otherwise """ self._readonly = state
[docs] def is_readonly(self) -> bool: """Return object readonly state Returns: bool: True if object is readonly, False otherwise """ return self._readonly
[docs] def set_private(self, state: bool) -> None: """Set object as private Args: state: True if object is private, False otherwise """ self._private = state
[docs] def is_private(self) -> bool: """Return True if object is private Returns: bool: True if object is private, False otherwise """ return self._private
[docs] def select(self) -> None: """ Select the object and eventually change its appearance to highlight the fact that it's selected """ if self.selected: # Already selected return self.selected = True self.markerparam.update_item(self) self.invalidate_plot()
[docs] def unselect(self) -> None: """ Unselect the object and eventually restore its original appearance to highlight the fact that it's not selected anymore """ self.selected = False self.markerparam.update_item(self) self.invalidate_plot()
[docs] def hit_test(self, pos: QPointF) -> tuple[float, float, bool, None]: """Return a tuple (distance, attach point, inside, other_object) Args: pos: Position Returns: tuple: Tuple with four elements: (distance, attach point, inside, other_object). Description of the returned values: * distance: distance in pixels (canvas coordinates) to the closest attach point * attach point: handle of the attach point * inside: True if the mouse button has been clicked inside the object * other_object: if not None, reference of the object which will be considered as hit instead of self """ plot = self.plot() xc, yc = pos.x(), pos.y() x = plot.transform(self.xAxis(), self.xValue()) y = plot.transform(self.yAxis(), self.yValue()) ms = self.markerparam.markerstyle # The following assert has no purpose except reminding that the # markerstyle is one of the MARKERSTYLES dictionary values, in case # this dictionary evolves in the future (this should not fail): assert ms in list(MARKERSTYLES.values()) if ms == "NoLine": return math.sqrt((x - xc) ** 2 + (y - yc) ** 2), 0, False, None elif ms == "HLine": return math.sqrt((y - yc) ** 2), 0, False, None elif ms == "VLine": return math.sqrt((x - xc) ** 2), 0, False, None elif ms == "Cross": return math.sqrt(min((x - xc) ** 2, (y - yc) ** 2)), 0, False, None
[docs] def update_item_parameters(self) -> None: """Update item parameters (dataset) from object properties""" self.markerparam.update_param(self)
[docs] def get_item_parameters(self, itemparams: ItemParameters) -> None: """ Appends datasets to the list of DataSets describing the parameters used to customize apearance of this item Args: itemparams: Item parameters """ self.update_item_parameters() itemparams.add("MarkerParam", self, self.markerparam)
[docs] def set_item_parameters(self, itemparams): """ Change the appearance of this item according to the parameter set provided params is a list of Datasets of the same types as those returned by get_item_parameters """ update_dataset( self.markerparam, itemparams.get("MarkerParam"), visible_only=True ) self.markerparam.update_item(self) if self.selected: self.select()
[docs] def move_local_point_to(self, handle: int, pos: QPointF, ctrl: bool = None) -> None: """Move a handle as returned by hit_test to the new position Args: handle: Handle pos: Position ctrl: True if <Ctrl> button is being pressed, False otherwise """ x, y = canvas_to_axes(self, pos) self.set_pos(x, y)
[docs] def move_local_shape(self, old_pos: QPointF, new_pos: QPointF) -> None: """Translate the shape such that old_pos becomes new_pos in canvas coordinates Args: old_pos: Old position new_pos: New position """
# This methods is never called because marker is not a shape (but a point)
[docs] def move_with_selection(self, delta_x: float, delta_y: float) -> None: """Translate the item together with other selected items Args: delta_x: Translation in plot coordinates along x-axis delta_y: Translation in plot coordinates along y-axis """
# This methods is never called because marker is not a shape (but a point) # ------Public API-----------------------------------------------------------
[docs] def set_style(self, section: str, option: str) -> None: """Set style for this item Args: section: Section option: Option """ self.markerparam.read_config(CONF, section, option) self.markerparam.update_item(self)
[docs] def set_pos(self, x: float | None = None, y: float | None = None) -> None: """Set marker position Args: x: X value (if None, use current value) y: Y value (if None, use current value) """ if x is None: x = self.xValue() if y is None: y = self.yValue() if self.constraint_cb: x, y = self.constraint_cb(x, y) self.setValue(x, y) if self.plot(): self.plot().SIG_MARKER_CHANGED.emit(self)
[docs] def get_pos(self) -> tuple[float, float]: """Get marker position Returns: Tuple with two elements (x, y) """ return self.xValue(), self.yValue()
[docs] def set_markerstyle(self, style: str | int | None) -> None: """Set marker style Args: style: Marker style """ param = self.markerparam param.set_markerstyle(style) param.update_item(self)
[docs] def is_vertical(self) -> bool: """Is it a vertical cursor? Returns: True if this is a vertical cursor """ return self.lineStyle() == QwtPlotMarker.VLine
[docs] def is_horizontal(self) -> bool: """Is it a horizontal cursor? Returns: True if this is a horizontal cursor """ return self.lineStyle() == QwtPlotMarker.HLine
[docs] def center_handle(self, x: float, y: float) -> tuple[float, float]: r"""Center cursor handle depending on marker style (\|, -) Args: x: X value y: Y value Returns: Tuple with two elements (x, y) """ plot = self.plot() if plot is None: self._pending_center_handle = True else: self._pending_center_handle = False if self.is_vertical(): ymap = plot.canvasMap(self.yAxis()) y_top, y_bottom = ymap.s1(), ymap.s2() y = 0.5 * (y_top + y_bottom) elif self.is_horizontal(): xmap = plot.canvasMap(self.xAxis()) x_left, x_right = xmap.s1(), xmap.s2() x = 0.5 * (x_left + x_right) return x, y
[docs] def invalidate_plot(self) -> None: """Invalidate the plot to force a redraw""" plot = self.plot() if plot is not None: plot.invalidate()
[docs] def update_label(self) -> None: """Update label""" x, y = self.xValue(), self.yValue() plot: BasePlot = self.plot() if self.label_cb: label = self.label_cb(x, y) if label is None: return elif self.is_vertical(): # Format x-coordinate considering datetime axis if plot is not None and plot.get_axis_scale(self.xAxis()) == "datetime": x_formatted = plot.format_coordinate_value(x, self.xAxis()) label = f"x = {x_formatted}" else: label = f"x = {x:g}" elif self.is_horizontal(): # Format y-coordinate considering datetime axis if plot is not None and plot.get_axis_scale(self.yAxis()) == "datetime": y_formatted = plot.format_coordinate_value(y, self.yAxis()) label = f"y = {y_formatted}" else: label = f"y = {y:g}" else: # Format both coordinates considering datetime axes if plot is not None: x_formatted = x y_formatted = y if plot.get_axis_scale(self.xAxis()) == "datetime": x_formatted = plot.format_coordinate_value(x, self.xAxis()) else: x_formatted = f"{x:g}" if plot.get_axis_scale(self.yAxis()) == "datetime": y_formatted = plot.format_coordinate_value(y, self.yAxis()) else: y_formatted = f"{y:g}" label = f"x = {x_formatted}<br>y = {y_formatted}" else: label = f"x = {x:g}<br>y = {y:g}" text = self.label() text.setText(label) self.setLabel(text) plot = self.plot() if plot is not None: xaxis = plot.axisScaleDiv(self.xAxis()) if x < (xaxis.upperBound() + xaxis.lowerBound()) / 2: hor_alignment = QC.Qt.AlignRight else: hor_alignment = QC.Qt.AlignLeft yaxis = plot.axisScaleDiv(self.yAxis()) ymap = plot.canvasMap(self.yAxis()) y_top, y_bottom = ymap.s1(), ymap.s2() if y < 0.5 * (yaxis.upperBound() + yaxis.lowerBound()): if y_top > y_bottom: ver_alignment = QC.Qt.AlignBottom else: ver_alignment = QC.Qt.AlignTop else: if y_top > y_bottom: ver_alignment = QC.Qt.AlignTop else: ver_alignment = QC.Qt.AlignBottom self.setLabelAlignment(hor_alignment | ver_alignment)
assert_interfaces_valid(Marker)