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)