# -*- 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)