Source code for plotpy.tools.curve
# -*- coding: utf-8 -*-
"""Curve tools"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable
import numpy as np
import scipy.integrate as spt
from guidata.dataset import ChoiceItem, DataSet, FloatItem, IntItem
from guidata.qthelpers import execenv
from guidata.widgets.arrayeditor import ArrayEditor
from qtpy import QtCore as QC
from qtpy import QtGui as QG
from qtpy import QtWidgets as QW
from plotpy.config import _
from plotpy.constants import SHAPE_Z_OFFSET
from plotpy.coords import axes_to_canvas, canvas_to_axes
from plotpy.events import (
KeyEventMatch,
QtDragHandler,
StandardKeyMatch,
StatefulEventFilter,
setup_standard_tool_filter,
)
from plotpy.interfaces import ICurveItemType
from plotpy.items import Marker, XRangeSelection, YRangeSelection
from plotpy.items.curve.base import CurveItem
from plotpy.tools.base import (
DefaultToolbarID,
InteractiveTool,
LastItemHolder,
ToggleTool,
)
from plotpy.tools.cursor import BaseCursorTool
if TYPE_CHECKING:
from plotpy.items.label import DataInfoLabel
from plotpy.plot.base import BasePlot
from plotpy.plot.manager import PlotManager
class BaseRangeCursorTool(BaseCursorTool):
"""Base range cursor tool
Args:
manager: PlotManager Instance
toolbar_id: Toolbar Id to use. Defaults to DefaultToolbarID.
title: Tool name. Defaults to None.
icon: Tool icon path. Defaults to None.
tip: Available tip. Defaults to None.
switch_to_default_tool: Wether to use as the default tool or not.
Defaults to None.
"""
TITLE = ""
ICON = "" # No icon by default, subclasses should set this
SWITCH_TO_DEFAULT_TOOL = True
LABELFUNCS: tuple[tuple[str, Callable[..., Any]], ...] | None = None
SHAPECLASS: type[XRangeSelection | YRangeSelection] = XRangeSelection
def __init__(
self,
manager: PlotManager,
labelfuncs: tuple[tuple[str, Callable[..., Any]], ...] | None = None,
toolbar_id: Any = DefaultToolbarID,
title: str | None = None,
icon: str | None = None,
tip: str | None = None,
) -> None:
super().__init__(manager, toolbar_id, title=title, icon=icon, tip=tip)
self.last_item_holder = LastItemHolder(ICurveItemType)
self.label: DataInfoLabel | None = None
self.labelfuncs = labelfuncs or self.LABELFUNCS
def create_shape(self) -> XRangeSelection | YRangeSelection:
"""Create shape associated with the tool"""
assert self.SHAPECLASS is not None, "SHAPECLASS must be set in subclasses"
return self.SHAPECLASS(0, 0)
def get_label_title(self) -> str | None:
"""Return label title"""
return self.TITLE
def get_computation_specs(self):
"""Return computation specs"""
raise NotImplementedError
def create_label(self) -> DataInfoLabel:
"""Create label associated with the tool"""
# The following import is here to avoid circular imports
# pylint: disable=import-outside-toplevel
from plotpy.builder import make
plot = self.manager.get_plot()
title = self.get_label_title()
specs = self.get_computation_specs()
label = make.computations(self.shape, "TL", specs, title)
label.attach(plot)
label.setZ(plot.get_max_z() + 1)
label.setVisible(True)
return label
def move(self, filter: StatefulEventFilter, event: QG.QMouseEvent) -> None:
"""Move tool action
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
"""
super().move(filter, event)
if self.label is None:
self.label = self.create_label()
def end_move(self, filter: StatefulEventFilter, event: QG.QMouseEvent) -> None:
"""End shape move
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
"""
super().end_move(filter, event)
if self.label is not None:
filter.plot.add_item_with_z_offset(self.label, SHAPE_Z_OFFSET)
self.label = None
[docs]
class CurveStatsTool(BaseRangeCursorTool):
"""X-range curve statistics tool
Args:
manager: PlotManager Instance
toolbar_id: Toolbar Id to use. Defaults to DefaultToolbarID.
title: Tool name. Defaults to None.
icon: Tool icon path. Defaults to None.
tip: Available tip. Defaults to None.
switch_to_default_tool: Wether to use as the default tool or not.
Defaults to None.
"""
TITLE = _("Signal statistics")
ICON = "xrange.png"
LABELFUNCS: tuple[tuple[str, Callable[..., Any]], ...] = (
("%g < x < %g", lambda *args: (np.nanmin(args[0]), np.nanmax(args[0]))),
("%g < y < %g", lambda *args: (np.nanmin(args[1]), np.nanmax(args[1]))),
("∆x=%g", lambda *args: np.nanmax(args[0]) - np.nanmin(args[0])),
("∆y=%g", lambda *args: np.nanmax(args[1]) - np.nanmin(args[1])),
("<y>=%g", lambda *args: np.nanmean(args[1])),
("σ(y)=%g", lambda *args: np.nanstd(args[1])),
("∑(y)=%g", lambda *args: np.nansum(args[1])),
("∫ydx=%g", lambda *args: spt.trapezoid(args[1], args[0])),
)
SHAPECLASS = XRangeSelection
[docs]
def set_labelfuncs(
self, labelfuncs: tuple[tuple[str, Callable[..., Any]], ...]
) -> None:
"""Set label functions
Args:
labelfuncs: Label functions
Example:
.. code-block:: python
labelfuncs = (
("%g < x < %g", lambda *args: (args[0].min(), args[0].max())),
("%g < y < %g", lambda *args: (args[1].min(), args[1].max())),
("<y>=%g", lambda *args: args[1].mean()),
("σ(y)=%g", lambda *args: args[1].std()),
("∑(y)=%g", lambda *args: np.sum(args[1])),
("∫ydx=%g", lambda *args: spt.trapezoid(args[1], args[0])),
)
"""
self.labelfuncs = labelfuncs
[docs]
def get_label_title(self) -> str | None:
"""Return label title"""
curve = self.last_item_holder.update_from_selection(self.manager.get_plot())
return curve.title().text() if curve else None
[docs]
def get_computation_specs(self) -> list[tuple[CurveItem, str, Callable[..., Any]]]:
"""Return computation specs"""
curve = self.last_item_holder.update_from_selection(self.manager.get_plot())
return [(curve, label, func) for label, func in self.labelfuncs]
[docs]
def update_status(self, plot: BasePlot) -> None:
"""Update tool status
Args:
plot: BasePlot instance
"""
item = self.last_item_holder.update_from_selection(plot)
self.action.setEnabled(item is not None)
[docs]
class YRangeCursorTool(BaseRangeCursorTool):
"""Y-range cursor tool
Args:
manager: PlotManager Instance
toolbar_id: Toolbar Id to use. Defaults to DefaultToolbarID.
title: Tool name. Defaults to None.
icon: Tool icon path. Defaults to None.
tip: Available tip. Defaults to None.
switch_to_default_tool: Wether to use as the default tool or not.
Defaults to None.
"""
TITLE = _("Y-range")
ICON = "yrange.png"
LABELFUNCS: tuple[tuple[str, Callable[..., Any]], ...] = (
("%g < y < %g", lambda ymin, ymax: (min(ymin, ymax), max(ymin, ymax))),
("∆y=%g", lambda ymin, ymax: abs(ymax - ymin)),
)
SHAPECLASS = YRangeSelection
[docs]
def set_labelfuncs(
self, labelfuncs: tuple[tuple[str, Callable[..., Any]], ...]
) -> None:
"""Set label functions
Args:
labelfuncs: Label functions
Example:
.. code-block:: python
labelfuncs = (
("%g < y < %g", lambda ymin, ymax: (ymin, ymax)),
("∆y=%g", lambda ymin, ymax: ymax - ymin),
)
"""
self.labelfuncs = labelfuncs
[docs]
def get_computation_specs(self) -> list[tuple[str, Callable[..., Any]]]:
"""Return computation specs"""
return [(label, func) for label, func in self.labelfuncs]
[docs]
class AntiAliasingTool(ToggleTool):
"""Anti-aliasing tool
Args:
manager: PlotManager Instance
"""
def __init__(self, manager: PlotManager) -> None:
super().__init__(manager, _("Antialiasing (curves)"))
[docs]
def activate_command(self, plot: BasePlot, checked: bool) -> None:
"""Activate tool"""
plot.set_antialiasing(checked)
plot.replot()
[docs]
def update_status(self, plot: BasePlot) -> None:
"""Update tool status
Args:
plot: BasePlot instance
"""
self.action.setChecked(plot.antialiased)
[docs]
class SelectPointTool(InteractiveTool):
"""Curve point selection tool
Args:
manager: PlotManager Instance
mode: Selection mode. Defaults to "reuse".
on_active_item: Wether to use the active item or not. Defaults to False.
title: Tool name. Defaults to None.
icon: Tool icon path. Defaults to None.
tip: Available tip. Defaults to None.
end_callback: Callback function taking a Self instance as argument that will
be passed when the user stops dragging the point. Defaults to None.
toolbar_id: Toolbar Id to use. Defaults to DefaultToolbarID.
marker_style: Marker style. Defaults to None.
switch_to_default_tool: Wether to use as the default tool or not.
Defaults to None.
"""
TITLE = _("Point selection")
ICON = "point_selection.png"
MARKER_STYLE_SECT = "plot"
MARKER_STYLE_KEY = "marker/curve"
CURSOR = QC.Qt.CursorShape.PointingHandCursor
def __init__(
self,
manager: PlotManager,
mode: str = "reuse",
on_active_item: bool = False,
title: str | None = None,
icon: str | None = None,
tip: str | None = None,
end_callback: Callable[[SelectPointTool], Any] | None = None,
toolbar_id=DefaultToolbarID,
marker_style=None,
switch_to_default_tool=None,
) -> None:
super().__init__(
manager,
toolbar_id,
title=title,
icon=icon,
tip=tip,
switch_to_default_tool=switch_to_default_tool,
)
assert mode in ("reuse", "create")
self.mode = mode
self.end_callback = end_callback
self.marker = None
self.last_pos = None
self.on_active_item = on_active_item
if marker_style is not None:
self.marker_style_sect = marker_style[0]
self.marker_style_key = marker_style[1]
else:
self.marker_style_sect = self.MARKER_STYLE_SECT
self.marker_style_key = self.MARKER_STYLE_KEY
[docs]
def set_marker_style(self, marker: Marker) -> None:
"""Configure marker style
Args:
marker: Marker instance
"""
marker.set_style(self.marker_style_sect, self.marker_style_key)
[docs]
def setup_filter(self, baseplot: BasePlot) -> StatefulEventFilter:
"""Setup event filter
Args:
baseplot: BasePlot instance
Returns:
StatefulEventFilter instance
"""
filter = baseplot.filter
# Initialisation du filtre
start_state = filter.new_state()
# Bouton gauche :
handler = QtDragHandler(
filter, QC.Qt.MouseButton.LeftButton, start_state=start_state
)
handler.SIG_START_TRACKING.connect(self.start)
handler.SIG_MOVE.connect(self.move)
handler.SIG_STOP_NOT_MOVING.connect(self.stop)
handler.SIG_STOP_MOVING.connect(self.stop)
return setup_standard_tool_filter(filter, start_state)
[docs]
def start(self, filter: StatefulEventFilter, event: QG.QMouseEvent) -> None:
"""Start tool action
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
"""
if self.marker is None:
title = ""
if self.TITLE:
title = f"<b>{self.TITLE}</b><br>"
if self.on_active_item:
constraint_cb = filter.plot.on_active_curve
def label_cb(x, y):
return title + filter.plot.get_coordinates_str(x, y)
else:
constraint_cb = None
def label_cb(x, y):
return f"{title}x = {x:g}<br>y = {y:g}"
self.marker = Marker(label_cb=label_cb, constraint_cb=constraint_cb)
self.set_marker_style(self.marker)
self.marker.attach(filter.plot)
self.marker.setZ(filter.plot.get_max_z() + 1)
self.marker.setVisible(True)
[docs]
def stop(self, filter: StatefulEventFilter, event: QG.QMouseEvent) -> None:
"""Stop tool action
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
"""
self.move(filter, event)
if self.mode != "reuse":
self.marker.detach()
self.marker = None
if self.end_callback:
self.end_callback(self)
[docs]
def move(self, filter: StatefulEventFilter, event: QG.QMouseEvent) -> None:
"""Move tool action
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
"""
if self.marker is None:
return # something is wrong ...
self.marker.move_local_point_to(0, event.pos())
filter.plot.replot()
self.last_pos = self.marker.xValue(), self.marker.yValue()
[docs]
def get_coordinates(self) -> tuple[float, float] | None:
"""Get last coordinates"""
return self.last_pos
[docs]
class SelectPointsTool(InteractiveTool):
"""Curve points selection tool
Args:
manager: PlotManager Instance
mode: Selection mode. Defaults to "reuse".
on_active_item: Wether to use the active item or not. Defaults to False.
title: Tool name. Defaults to None.
icon: Tool icon path. Defaults to None.
tip: Available tip. Defaults to None.
end_callback: Callback function taking a Self instance as argument that will
be passed when the user stops dragging the point. Defaults to None.
toolbar_id: Toolbar Id to use. Defaults to DefaultToolbarID.
marker_style: Marker style. Defaults to None.
switch_to_default_tool: Wether to use as the default tool or not.
Defaults to None.
max_select: Maximum number of points to select. Defaults to None.
"""
TITLE = _("Multi-point selection")
ICON = "multipoint_selection.png"
MARKER_STYLE_SECT = "plot"
MARKER_STYLE_KEY = "marker/curve"
CURSOR = QC.Qt.CursorShape.PointingHandCursor
def __init__(
self,
manager,
mode="reuse",
on_active_item=True,
title=None,
icon=None,
tip=None,
end_callback: Callable[[SelectPointsTool], Any] | None = None,
toolbar_id=DefaultToolbarID,
marker_style=None,
switch_to_default_tool=None,
max_select: int | None = None,
) -> None:
super().__init__(
manager,
toolbar_id,
title=title,
icon=icon,
tip=tip,
switch_to_default_tool=switch_to_default_tool,
)
assert mode in ("reuse", "create")
self.mode = mode
self.end_callback = end_callback
self.current_location_marker: Marker | None = None
self.markers: dict[tuple[float, float], Marker] = {}
self.on_active_item = on_active_item
self.max_select = max_select
if marker_style is not None:
self.marker_style_sect = marker_style[0]
self.marker_style_key = marker_style[1]
else:
self.marker_style_sect = self.MARKER_STYLE_SECT
self.marker_style_key = self.MARKER_STYLE_KEY
[docs]
def set_marker_style(self, marker: Marker) -> None:
"""Configure marker style
Args:
marker: Marker instance
"""
marker.set_style(self.marker_style_sect, self.marker_style_key)
[docs]
def setup_filter(self, baseplot: BasePlot) -> StatefulEventFilter:
"""Setup event filter
Args:
baseplot: BasePlot instance
Returns:
StatefulEventFilter instance
"""
filter = baseplot.filter
# Initialisation du filtre
start_state = filter.new_state()
# Bouton gauche :
single_selection_handler = QtDragHandler(
filter,
QC.Qt.MouseButton.LeftButton,
start_state=start_state,
)
single_selection_handler.SIG_START_TRACKING.connect(self.start_single_selection)
single_selection_handler.SIG_MOVE.connect(self.move)
single_selection_handler.SIG_STOP_NOT_MOVING.connect(self.stop_single_selection)
single_selection_handler.SIG_STOP_MOVING.connect(self.stop_single_selection)
multi_selection_handler = QtDragHandler(
filter,
QC.Qt.MouseButton.LeftButton,
start_state=start_state,
mods=QC.Qt.KeyboardModifier.ControlModifier,
)
multi_selection_handler.SIG_START_TRACKING.connect(self.start_multi_selection)
multi_selection_handler.SIG_MOVE.connect(self.move)
multi_selection_handler.SIG_STOP_NOT_MOVING.connect(self.stop_multi_selection)
multi_selection_handler.SIG_STOP_MOVING.connect(self.stop_multi_selection)
return setup_standard_tool_filter(filter, start_state)
def _init_current_marker(
self,
filter: StatefulEventFilter,
event: QG.QMouseEvent,
force_new_marker=False,
title: str = "",
) -> None:
"""Initialize current marker
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
force_new_marker: Wether to force a new marker or not. Defaults to False.
title: Marker title. Defaults to "".
"""
plot = filter.plot
if plot is None:
return
if force_new_marker or self.current_location_marker is None:
title = title or f"<b>{self.TITLE} {len(self.markers)}</b><br>"
constraint_cb = plot.on_active_curve if self.on_active_item else None
label_cb = self.__new_label_cb(filter, title)
self.current_location_marker = Marker(
label_cb=label_cb, constraint_cb=constraint_cb
)
assert self.current_location_marker
self.set_marker_style(self.current_location_marker)
self.current_location_marker.attach(filter.plot)
self.current_location_marker.setZ(plot.get_max_z() + 1)
self.current_location_marker.setVisible(True)
[docs]
def start_single_selection(
self, filter: StatefulEventFilter, event: QG.QMouseEvent
) -> None:
"""Start single selection
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
"""
self._init_current_marker(filter, event, force_new_marker=True)
[docs]
def start_multi_selection(
self, filter: StatefulEventFilter, event: QG.QMouseEvent
) -> None:
"""Start multi selection
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
"""
self._init_current_marker(filter, event, force_new_marker=True)
assert self.current_location_marker
self.current_location_marker.label_cb = self.__new_label_cb(
filter, index=len(self.markers) + 1
)
[docs]
def move(self, filter: StatefulEventFilter, event: QG.QMouseEvent) -> None:
"""Move tool action
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
"""
plot = filter.plot
if plot is None:
return
if self.current_location_marker is None:
return # something is wrong ...
self.current_location_marker.move_local_point_to(0, event.pos())
plot.replot()
[docs]
def common_stop(self, filter: StatefulEventFilter, event: QG.QMouseEvent) -> None:
"""Common stop action
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
"""
self.move(filter, event)
if self.mode != "reuse" and self.current_location_marker is not None:
self.current_location_marker.detach()
self.current_location_marker = None
[docs]
def stop_single_selection(
self, filter: StatefulEventFilter, event: QG.QMouseEvent
) -> None:
"""Stop single selection
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
"""
assert self.current_location_marker
self.common_stop(filter, event)
# self.clear_markers((self.current_location_marker,))
self.clear_markers()
self.toggle_marker(self.current_location_marker)
self.update_labels(filter)
if self.end_callback:
self.end_callback(self)
[docs]
def stop_multi_selection(
self, filter: StatefulEventFilter, event: QG.QMouseEvent
) -> None:
"""Stop multi selection
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
"""
assert self.current_location_marker
self.common_stop(filter, event)
self.toggle_marker(self.current_location_marker)
if self.max_select is not None and len(self.markers) > self.max_select:
xy = next(iter(self.markers))
self.markers.pop(xy).detach()
self.update_labels(filter)
if self.end_callback:
self.end_callback(self)
[docs]
def toggle_marker(self, marker: Marker) -> bool:
"""Toggle marker
Args:
marker: Marker instance
Returns:
bool: Wether the marker was added or not
"""
assert marker
xy = marker.xValue(), marker.yValue()
entry_marker = self.markers.setdefault(xy, marker)
if entry_marker is not marker:
self.markers.pop(xy).detach()
entry_marker.detach()
marker.detach()
self.current_location_marker = None
return False
return True
[docs]
def clear_markers(self, exclude_detach: tuple[Marker, ...] | None = None) -> None:
"""Clear markers
Args:
exclude_detach: Markers to exclude from detachment. Defaults to None.
"""
exclude_detach = exclude_detach or tuple()
_ = list(
map(
lambda m: m.detach(),
filter(
lambda m: m not in exclude_detach,
self.markers.values(),
),
)
)
self.markers.clear()
[docs]
def update_labels(self, filter: StatefulEventFilter) -> None:
"""Update labels
Args:
filter: StatefulEventFilter instance
"""
for i, marker in enumerate(self.markers.values()):
marker.label_cb = self.__new_label_cb(filter, index=i + 1)
def __new_label_cb(
self, filter: StatefulEventFilter, title: str = "", index: int = 1
) -> Callable[[float, float], str]:
"""Create new label callback
Args:
filter: StatefulEventFilter instance
title: Title. Defaults to "".
index: Index. Defaults to 1.
Returns:
Label callback
"""
title = title or f"<b>{self.TITLE} - {index}</b><br>"
if self.on_active_item:
def label_cb(x, y): # type: ignore
return title + filter.plot.get_coordinates_str(x, y)
else:
def label_cb(x, y):
return f"{title}{' - ' if title else ''}{index} x = {x:g}<br>y = {y:g}"
return label_cb
[docs]
def get_coordinates(self) -> tuple[tuple[float, float], ...]:
"""Get all selected coordinates"""
return tuple(self.markers.keys())
class InsertionDataSet(DataSet):
"""Insertion parameters"""
__index = IntItem(_("Insertion index"), min=0, default=0)
index = __index
value = FloatItem(_("New value"), default=0.0)
index_offset = ChoiceItem(
_("Location"), choices=[_("Before"), _("After")], default=0
)
@classmethod
def set_max_index(cls, max_index: int):
"""Sets the maximum index value for the index field
Args:
max_index: max index value
"""
cls.index.set_prop("data", max=max_index)
[docs]
class EditPointTool(InteractiveTool):
"""Curve point edition tool
Args:
manager: PlotManager Instance
toolbar_id: Toolbar Id to use . Defaults to DefaultToolbarID.
title: Tool name. Defaults to None.
icon: Tool icon path. Defaults to None.
tip: Available tip. Defaults to None.
switch_to_default_tool: Wether to use as the default tool or not.
Defaults to None.
end_callback: Callback function taking a Self instance as argument that will
be passed when the user stops dragging the point. Defaults to None.
"""
TITLE = _("Edit point")
ICON = "edit_point_selection.png"
MARKER_STYLE_SECT = "plot"
MARKER_STYLE_KEY = "marker/curve"
CURSOR = QC.Qt.CursorShape.PointingHandCursor
def __init__(
self,
manager: PlotManager,
toolbar_id=DefaultToolbarID,
title: str | None = None,
icon: str | None = None,
tip: str | None = None,
switch_to_default_tool: bool | None = None,
end_callback: Callable[[EditPointTool], Any] | None = None,
) -> None:
super().__init__(manager, toolbar_id, title, icon, tip, switch_to_default_tool)
self.__x: np.ndarray | None = None
self.__y: np.ndarray | None = None
self.__x_bkp: np.ndarray | None = None
self.__y_bkp: np.ndarray | None = None
self.__current_location_marker: Marker | None = None
self.__idx: int = 0
self.__dsampled_idx: int = 0
self.__lower_upper_x_bounds: tuple[float, float] = 0, 0
self.marker_style_sect = self.MARKER_STYLE_SECT
self.marker_style_key = self.MARKER_STYLE_KEY
self.__indexed_changes: dict[CurveItem, dict[int, tuple[float, float]]] = {}
self.__selection_threshold: float = 0.0
self.end_callback = end_callback
self.__dsampling = 1 # synchronized with the active CurveItem
self.__curve_item_array_backup: dict[
CurveItem, tuple[np.ndarray, np.ndarray]
] = {}
self.dragging_point = False
[docs]
def set_marker_style(self, marker: Marker) -> None:
"""Configure marker style
Args:
marker: Marker instance
"""
marker.set_style(self.marker_style_sect, self.marker_style_key)
def __get_selection_threshold(self, filter: StatefulEventFilter) -> float:
"""Computes a distance threshold from the current point selected (self._x,
self._y) and the previous and next curve points. This threshold can be used to
know if the user is close enough to the selected point to be allowed to move it.
The threshold will be larger if the curve points are far from each other and
lower if they are closer (locally adjusted).
Args:
filter: StatefulEventFilter instance
Returns:
Selection threshold (distance)
"""
curve_item = self.__get_active_curve_item(filter)
# Check if a point has been selected
if self.__x is not None and self.__y is not None:
# get previous and next curve points coordinates with bound checking
lower_index_bound = max(self.__dsampled_idx - 1, 0)
higher_index_bound = min(
self.downsampled_x.shape[0], self.__dsampled_idx + 2
)
# Get x and y arrays of neigboring points (at coordinates i-1, i, i+1)
axes_x_points = self.downsampled_x[lower_index_bound:higher_index_bound]
axes_y_points = self.downsampled_y[lower_index_bound:higher_index_bound]
# Create empty array where axes coordinates will be converted to canvas
# coordinates
canva_x_points, canva_y_points = (
np.zeros(axes_x_points.size),
np.zeros(axes_x_points.size),
)
# Convert axes coordinates to canvas coordinates
for i, (x, y) in enumerate(zip(axes_x_points, axes_y_points)):
new_x, new_y = axes_to_canvas(curve_item, x, y) or (None, None)
assert new_x is not None and new_y is not None
canva_x_points[i], canva_y_points[i] = new_x, new_y
# computes the delta between the current point and its neighbors
dx, dy = np.ediff1d(canva_x_points), np.ediff1d(canva_y_points)
# returns the mean distance between the current point and its neighbors
return np.linalg.norm((dx, dy), axis=0).min() / 2
return 0.0
[docs]
def setup_filter(self, baseplot: BasePlot) -> StatefulEventFilter:
"""Setup event filter
Args:
baseplot: BasePlot instance
Returns:
StatefulEventFilter instance
"""
filter = baseplot.filter
# Initialisation du filtre
start_state = filter.new_state()
drag_handler = QtDragHandler(
filter, btn=QC.Qt.MouseButton.LeftButton, start_state=start_state
)
drag_handler.SIG_START_TRACKING.connect(self.start)
drag_handler.SIG_MOVE.connect(self.move_point)
drag_handler.SIG_STOP_MOVING.connect(self.stop)
drag_handler.SIG_STOP_NOT_MOVING.connect(self.stop)
undo_key_match = StandardKeyMatch(QG.QKeySequence.StandardKey.Undo)
filter.add_event(
start_state, undo_key_match, self.undo_curve_modifications, start_state
)
insert_keys_match = KeyEventMatch(
((QC.Qt.Key.Key_I, QC.Qt.KeyboardModifier.ControlModifier),)
)
filter.add_event(
start_state,
insert_keys_match,
self.insert_point_at_selection,
start_state,
)
return setup_standard_tool_filter(filter, start_state)
[docs]
def undo_curve_modifications(
self, filter: StatefulEventFilter, _event: QC.QEvent | None
) -> None:
"""Undo all curve modifications
Args:
filter: StatefulEventFilter instance
_event: Event instance
"""
curve_item = self.__get_active_curve_item(filter)
x_arr, y_arr = self.__curve_item_array_backup.get(curve_item, (None, None))
if (
x_arr is not None
and y_arr is not None
and self.__x is not None
and self.__y is not None
):
self.__x, self.__y = x_arr, y_arr
curve_item.set_data(x_arr, y_arr)
filter.plot.replot()
self.__get_current_marker(filter).detach()
self.__current_location_marker = None
self.__x = self.__y = None
self.__indexed_changes.get(curve_item, {}).clear()
self.__idx = self.__dsampled_idx = 0
[docs]
def trigger_insert_point_at_selection(self) -> None:
"""Trigger insert point at selection"""
plot: BasePlot | None = self.get_active_plot()
if plot is None:
return
self.insert_point_at_selection(plot.filter, None)
[docs]
def insert_point_at_selection(
self, filter: StatefulEventFilter, _event: QC.QEvent | None = None
) -> None:
"""Insert point at selection
Args:
filter: StatefulEventFilter instance
_event: Event instance
"""
curve_item = self.__get_active_curve_item(filter)
if (
self.__current_location_marker is None
or self.__y is None
or self.__x is None
):
QW.QMessageBox.warning(
filter.plot,
_("Insert point"),
_(
"Before inserting a new point, "
"please select an existing curve point."
),
)
return
param = InsertionDataSet(title=_("Insert new value"), icon="insert.png")
param.set_max_index(self.__x.size - 1)
param.index = self.__idx
param.value = self.__y[self.__idx - 1 : self.__idx + 1].mean()
if param.edit() or execenv.unattended:
insertion_index: int = param.index + param.index_offset # type: ignore
new_x: float = self.__x[insertion_index - 1 : insertion_index + 1].mean()
self.__x = np.insert(self.__x, insertion_index, new_x) # type: ignore
self.__y = np.insert(self.__y, insertion_index, param.value) # type: ignore
curve_item.set_data(self.__x, self.__y)
new_pos = axes_to_canvas(curve_item, new_x, param.value) # type: ignore
self.__current_location_marker.move_local_point_to(
0,
QC.QPointF(*new_pos), # type: ignore
)
filter.plot.replot() # Needed on non-Windows platforms to update the view
def __get_active_curve_item(self, filter: StatefulEventFilter) -> CurveItem:
"""Get active curve item. Simple method to avoid type checking errors.
Args:
filter: StatefulEventFilter instance
Returns:
curve item
"""
assert isinstance(
curve_item := filter.plot.get_last_active_item(ICurveItemType), CurveItem
)
return curve_item
def __get_current_marker(self, filter: StatefulEventFilter) -> Marker:
"""Returns a marker instance. If the marker does not exist, it is created and
returns it.
Args:
filter: StatefulEventFilter instance
Returns:
Marker instance
"""
return self.__current_location_marker or self.__init_current_marker(filter)
def __get_x_bounds(self, x_array: np.ndarray, index: int) -> tuple[float, float]:
"""Returns the x values of the previous and next points and performs an out
of bound check in case the given index is the first or last of the array.
Args:
x_array: X array
index: Index
Returns:
X bounds
"""
lower_bound_index = max(index - 1, 0)
upper_bound_index = min(x_array.size - 1, index + 1)
return x_array[lower_bound_index], x_array[upper_bound_index]
@property
def downsampled_x(self) -> np.ndarray:
"""Downsampled x array"""
assert self.__x is not None
if self.__dsampling > 1:
return self.__x[:: self.__dsampling]
return self.__x
@property
def downsampled_y(self) -> np.ndarray:
"""Downsampled y array"""
assert self.__y is not None
if self.__dsampling > 1:
return self.__y[:: self.__dsampling]
return self.__y
[docs]
def start(self, filter: StatefulEventFilter, event: QG.QMouseEvent) -> None:
"""Start tool action
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
"""
self.dragging_point = False
curve_item = self.__get_active_curve_item(filter)
if self.__current_location_marker is not None:
self.__current_location_marker.detach()
self.__current_location_marker = None
if curve_item is not None:
curve_x, curve_y = curve_item.get_data()
self.__curve_item_array_backup.setdefault(curve_item, (curve_x, curve_y))
if self.__x is not curve_x or self.__x is None or self.__y is not curve_y:
self.__x, self.__y = curve_x.copy(), curve_y.copy()
self.__dsampling: int = (
1 if not curve_item.param.use_dsamp else curve_item.param.dsamp_factor
) # type: ignore
self.__current_location_marker = self.__get_current_marker(filter)
self.__current_location_marker.move_local_point_to(0, event.pos())
x_value = self.__current_location_marker.xValue()
self.__dsampled_idx = min(
int(np.searchsorted(self.downsampled_x, x_value)),
len(self.downsampled_x) - 1,
)
self.__idx = self.__dsampled_idx * self.__dsampling # type: ignore
self.__lower_upper_x_bounds = self.__get_x_bounds(curve_x, self.__idx)
self.__selection_threshold = self.__get_selection_threshold(filter)
filter.plot.replot()
[docs]
def move_point(self, filter: StatefulEventFilter, event: QG.QMouseEvent) -> None:
"""Move point
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
"""
self.__current_location_marker = self.__get_current_marker(filter)
curve_item = self.__get_active_curve_item(filter)
drag_cursor_pos: QC.QPoint = event.pos()
new_x, new_y = canvas_to_axes(curve_item, drag_cursor_pos) or (None, None)
marker_canva_x, marker_canva_y = axes_to_canvas(
curve_item, *self.__current_location_marker.get_pos()
) or (0.0, 0.0)
cursor_distance_to_marker = float(
np.linalg.norm(
(
marker_canva_x - drag_cursor_pos.x(),
marker_canva_y - drag_cursor_pos.y(),
)
)
)
if (
new_x is not None
and new_y is not None
and cursor_distance_to_marker < self.__selection_threshold
):
self.dragging_point = True
self.__selection_threshold = 10_000
new_x = min(
max(self.__lower_upper_x_bounds[0], new_x),
self.__lower_upper_x_bounds[1],
)
assert self.__x is not None and self.__y is not None
(
self.downsampled_x[self.__dsampled_idx],
self.downsampled_y[self.__dsampled_idx],
) = (new_x, new_y)
curve_item.set_data(self.__x, self.__y)
self.__current_location_marker.constraint_cb = (
self.__current_location_marker.center_handle
)
marker_x, marker_y = axes_to_canvas(curve_item, new_x, new_y) or (0, 0)
self.__current_location_marker.move_local_point_to(
0, QC.QPointF(marker_x, marker_y)
)
filter.plot.replot()
[docs]
def stop(self, filter: StatefulEventFilter, event: QG.QMouseEvent) -> None:
"""Stop tool action and save new x and y coordinates.
Args:
filter: StatefulEventFilter instance
event: Qt mouse event
"""
if self.dragging_point:
self.move_point(filter, event)
self.dragging_point = False
curve_item = self.__get_active_curve_item(filter)
if not (isinstance(self.__x, np.ndarray) and isinstance(self.__y, np.ndarray)):
return
self.__x_bkp, self.__y_bkp = self.__curve_item_array_backup[curve_item]
if self.__idx < self.__x_bkp.size and (
self.__x[self.__idx] != self.__x_bkp[self.__idx]
or self.__y[self.__idx] != self.__y_bkp[self.__idx]
):
self.__indexed_changes.setdefault(curve_item, {})[self.__idx] = (
self.__x[self.__idx],
self.__y[self.__idx],
)
if self.end_callback is not None:
self.end_callback(self)
def __init_current_marker(
self,
filter: StatefulEventFilter,
) -> Marker:
"""Initialize current marker
Args:
filter: StatefulEventFilter instance
Returns:
Marker instance
"""
plot = filter.plot
marker = Marker(
label_cb=lambda x, y: f"{x=:.4f}, {y=:.4f}",
constraint_cb=plot.on_active_curve,
)
marker.set_style(self.marker_style_sect, self.marker_style_key)
marker.attach(filter.plot)
marker.setZ(plot.get_max_z() + 1)
marker.setVisible(True)
return marker
[docs]
def get_changes(self) -> dict[CurveItem, dict[int, tuple[float, float]]]:
"""Get changes"""
return self.__indexed_changes
[docs]
def get_arrays(self) -> tuple[np.ndarray | None, np.ndarray | None]:
"""Get arrays"""
return self.__x, self.__y
[docs]
def get_initial_arrays(self) -> tuple[np.ndarray | None, np.ndarray | None]:
"""Get initial arrays"""
return self.__x_bkp, self.__y_bkp
[docs]
def reset_arrays(self) -> None:
"""Reset tool arrays to initial values and reset curve item data"""
self.__x = self.__y = None
for curve_item, (x_arr, y_arr) in self.__curve_item_array_backup.items():
curve_item.set_data(x_arr, y_arr)
self.__indexed_changes.clear()
[docs]
class DownSamplingTool(ToggleTool):
"""Downsample curve tool
Args:
manager: PlotManager Instance
toolbar_id: Toolbar Id to use . Defaults to DefaultToolbarID.
"""
def __init__(self, manager: PlotManager) -> None:
super().__init__(manager, _("Downsample"))
# No icon here, on purpose (because, on Windows, the toggle state is not
# clearly visible with the icon in the context menu)
[docs]
def activate_command(self, plot: BasePlot, checked: bool) -> None:
"""Activate tool
Args:
plot: BasePlot instance
checked: Wether the tool is checked or not
"""
curve_item: CurveItem | None = plot.get_last_active_item(ICurveItemType)
if curve_item is not None:
curve_item.param.use_dsamp = checked
curve_item.update_params()
[docs]
def update_status(self, plot: BasePlot) -> None:
"""Update tool status
Args:
plot: BasePlot instance
"""
item: CurveItem | None = plot.get_last_active_item(ICurveItemType)
self.action.setEnabled(item is not None)
if item is not None and self.action is not None:
self.action.setChecked(item.param.use_dsamp)
def export_curve_data(item: CurveItem) -> None:
"""Export curve item data to text file
Args:
item: curve item
"""
item_data = item.get_data()
if len(item_data) > 2:
x, y, dx, dy = item_data
array_list = [x, y]
if dx is not None:
array_list.append(dx)
if dy is not None:
array_list.append(dy)
data = np.array(array_list).T
else:
x, y = item_data
data = np.array([x, y]).T
plot = item.plot()
title = _("Export")
if item.param.label:
title += f" ({item.param.label})"
fname, _f = QW.QFileDialog.getSaveFileName(
plot, title, "", _("Text file") + " (*.txt)"
)
if fname:
try:
np.savetxt(str(fname), data, delimiter=",")
except RuntimeError as error:
QW.QMessageBox.critical(
plot,
_("Export"),
_("Unable to export item data.")
+ "<br><br>"
+ _("Error message:")
+ "<br>"
+ str(error),
)
def edit_curve_data(item: CurveItem) -> None:
"""Edit curve item data in array editor
Args:
item: curve item
"""
item_data = item.get_data()
if len(item_data) > 2:
x, y, dx, dy = item_data
array_list = [x, y]
if dx is not None:
array_list.append(dx)
if dy is not None:
array_list.append(dy)
data = np.array(array_list).T
else:
x, y = item_data
data = np.array([x, y]).T
dialog = ArrayEditor(item.plot())
dialog.setup_and_check(data)
if dialog.exec():
if data.shape[1] > 2:
if data.shape[1] == 3:
x, y, tmp = data.T
if dx is not None:
dx = tmp
else:
dy = tmp
else:
x, y, dx, dy = data.T
item.set_data(x, y, dx, dy)
else:
x, y = data.T
item.set_data(x, y)