mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-01-01 11:31:19 +01:00
1109 lines
35 KiB
Python
1109 lines
35 KiB
Python
from __future__ import annotations
|
|
|
|
from enum import Enum
|
|
|
|
import numpy as np
|
|
import pyqtgraph as pg
|
|
from bec_lib import bec_logger
|
|
from qtpy.QtCore import QPoint, QPointF, Qt, Signal
|
|
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
|
|
|
|
from bec_widgets.utils import ConnectionConfig, Crosshair, EntryValidator
|
|
from bec_widgets.utils.bec_widget import BECWidget
|
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
|
from bec_widgets.utils.fps_counter import FPSCounter
|
|
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
|
|
from bec_widgets.utils.round_frame import RoundedFrame
|
|
from bec_widgets.utils.side_panel import SidePanel
|
|
from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle
|
|
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
|
from bec_widgets.utils.widget_state_manager import WidgetStateManager
|
|
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
|
from bec_widgets.widgets.plots.setting_menus.axis_settings import AxisSettings
|
|
from bec_widgets.widgets.plots.toolbar_components.axis_settings_popup import (
|
|
AxisSettingsPopupConnection,
|
|
axis_popup_bundle,
|
|
)
|
|
from bec_widgets.widgets.plots.toolbar_components.mouse_interactions import (
|
|
MouseInteractionConnection,
|
|
mouse_interaction_bundle,
|
|
)
|
|
from bec_widgets.widgets.plots.toolbar_components.plot_export import (
|
|
PlotExportConnection,
|
|
plot_export_bundle,
|
|
)
|
|
from bec_widgets.widgets.plots.toolbar_components.roi import RoiConnection, roi_bundle
|
|
|
|
logger = bec_logger.logger
|
|
|
|
|
|
class BECViewBox(pg.ViewBox):
|
|
sigPaint = Signal()
|
|
|
|
def paint(self, painter, opt, widget):
|
|
super().paint(painter, opt, widget)
|
|
self.sigPaint.emit()
|
|
|
|
def itemBoundsChanged(self, item):
|
|
self._itemBoundsCache.pop(item, None)
|
|
if (self.state["autoRange"][0] is not False) or (self.state["autoRange"][1] is not False):
|
|
# check if the call is coming from a mouse-move event
|
|
if hasattr(item, "skip_auto_range") and item.skip_auto_range:
|
|
return
|
|
self._autoRangeNeedsUpdate = True
|
|
self.update()
|
|
|
|
|
|
class UIMode(Enum):
|
|
NONE = 0
|
|
POPUP = 1
|
|
SIDE = 2
|
|
|
|
|
|
class PlotBase(BECWidget, QWidget):
|
|
PLUGIN = False
|
|
RPC = False
|
|
|
|
# Custom Signals
|
|
property_changed = Signal(str, object)
|
|
crosshair_position_changed = Signal(tuple)
|
|
crosshair_position_clicked = Signal(tuple)
|
|
crosshair_coordinates_changed = Signal(tuple)
|
|
crosshair_coordinates_clicked = Signal(tuple)
|
|
|
|
def __init__(
|
|
self,
|
|
parent: QWidget | None = None,
|
|
config: ConnectionConfig | None = None,
|
|
client=None,
|
|
gui_id: str | None = None,
|
|
popups: bool = True,
|
|
**kwargs,
|
|
) -> None:
|
|
if config is None:
|
|
config = ConnectionConfig(widget_class=self.__class__.__name__)
|
|
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
|
|
|
# For PropertyManager identification
|
|
self.get_bec_shortcuts()
|
|
|
|
# Layout Management
|
|
self.layout = QVBoxLayout(self)
|
|
self.layout.setContentsMargins(0, 0, 0, 0)
|
|
self.layout.setSpacing(0)
|
|
self.layout_manager = LayoutManagerWidget(parent=self)
|
|
self.layout_manager.layout.setContentsMargins(0, 0, 0, 0)
|
|
self.layout_manager.layout.setSpacing(0)
|
|
|
|
# Property Manager
|
|
self.state_manager = WidgetStateManager(self)
|
|
|
|
# Entry Validator
|
|
self.entry_validator = EntryValidator(self.dev)
|
|
|
|
# Base widgets elements
|
|
self._popups = popups
|
|
self._ui_mode = UIMode.POPUP if popups else UIMode.SIDE
|
|
self.axis_settings_dialog = None
|
|
self.plot_widget = pg.GraphicsLayoutWidget(parent=self)
|
|
self.plot_widget.ci.setContentsMargins(0, 0, 0, 0)
|
|
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
|
|
self.plot_widget.addItem(self.plot_item)
|
|
self.plot_item.visible_items = lambda: self.visible_items
|
|
self.side_panel = SidePanel(self, orientation="left", panel_max_width=280)
|
|
|
|
# PlotItem Addons
|
|
self.plot_item.addLegend()
|
|
self.crosshair = None
|
|
self.fps_monitor = None
|
|
self.fps_label = QLabel(alignment=Qt.AlignmentFlag.AlignRight)
|
|
self._user_x_label = ""
|
|
self._x_label_suffix = ""
|
|
self._x_axis_units = ""
|
|
self._user_y_label = ""
|
|
self._y_label_suffix = ""
|
|
self._y_axis_units = ""
|
|
self._minimal_crosshair_precision = 3
|
|
|
|
# Plot Indicator Items
|
|
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
|
|
self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item)
|
|
|
|
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
|
|
self._init_toolbar()
|
|
|
|
self._init_ui()
|
|
|
|
self._connect_to_theme_change()
|
|
self._update_theme(None)
|
|
|
|
def apply_theme(self, theme: str):
|
|
self.round_plot_widget.apply_theme(theme)
|
|
|
|
def _init_ui(self):
|
|
self.layout.addWidget(self.layout_manager)
|
|
self.round_plot_widget = RoundedFrame(parent=self, content_widget=self.plot_widget)
|
|
self.round_plot_widget.setProperty("variant", "plot_background")
|
|
self.round_plot_widget.setProperty("frameless", True)
|
|
|
|
self.layout_manager.add_widget(self.round_plot_widget)
|
|
self.layout_manager.add_widget_relative(self.fps_label, self.round_plot_widget, "top")
|
|
self.fps_label.hide()
|
|
self.layout_manager.add_widget_relative(self.side_panel, self.round_plot_widget, "left")
|
|
self.layout_manager.add_widget_relative(self.toolbar, self.fps_label, "top")
|
|
|
|
self.ui_mode = self._ui_mode # to initiate the first time
|
|
|
|
# PlotItem ViewBox Signals
|
|
self.plot_item.vb.sigStateChanged.connect(self.viewbox_state_changed)
|
|
|
|
def _init_toolbar(self):
|
|
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
|
|
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))
|
|
self.toolbar.add_bundle(mouse_interaction_bundle(self.toolbar.components))
|
|
self.toolbar.add_bundle(roi_bundle(self.toolbar.components))
|
|
self.toolbar.add_bundle(axis_popup_bundle(self.toolbar.components))
|
|
|
|
self.toolbar.connect_bundle(
|
|
"plot_base", PlotExportConnection(self.toolbar.components, self)
|
|
)
|
|
self.toolbar.connect_bundle(
|
|
"plot_base", PerformanceConnection(self.toolbar.components, self)
|
|
)
|
|
self.toolbar.connect_bundle(
|
|
"plot_base", MouseInteractionConnection(self.toolbar.components, self)
|
|
)
|
|
self.toolbar.connect_bundle("plot_base", RoiConnection(self.toolbar.components, self))
|
|
self.toolbar.connect_bundle(
|
|
"plot_base", AxisSettingsPopupConnection(self.toolbar.components, self)
|
|
)
|
|
|
|
# hide some options by default
|
|
self.toolbar.toggle_action_visibility("fps_monitor", False)
|
|
|
|
# Get default viewbox state
|
|
self.toolbar.show_bundles(
|
|
["plot_export", "mouse_interaction", "roi", "performance", "axis_popup"]
|
|
)
|
|
|
|
def add_side_menus(self):
|
|
"""Adds multiple menus to the side panel."""
|
|
# Setting Axis Widget
|
|
try:
|
|
axis_setting = AxisSettings(parent=self, target_widget=self)
|
|
self.side_panel.add_menu(
|
|
action_id="axis",
|
|
icon_name="settings",
|
|
tooltip="Show Axis Settings",
|
|
widget=axis_setting,
|
|
title="Axis Settings",
|
|
)
|
|
except ValueError:
|
|
return
|
|
|
|
def reset_legend(self):
|
|
"""In the case that the legend is not visible, reset it to be visible to top left corner"""
|
|
self.plot_item.legend.autoAnchor(50)
|
|
|
|
################################################################################
|
|
# Toggle UI Elements
|
|
################################################################################
|
|
@property
|
|
def ui_mode(self) -> UIMode:
|
|
"""
|
|
Get the UI mode.
|
|
"""
|
|
return self._ui_mode
|
|
|
|
@ui_mode.setter
|
|
def ui_mode(self, mode: UIMode):
|
|
"""
|
|
Set the UI mode.
|
|
|
|
Args:
|
|
mode(UIMode): The UI mode to set.
|
|
"""
|
|
if not isinstance(mode, UIMode):
|
|
raise ValueError("ui_mode must be an instance of UIMode")
|
|
self._ui_mode = mode
|
|
|
|
# Now, apply the new mode:
|
|
if mode == UIMode.POPUP:
|
|
shown_bundles = self.toolbar.shown_bundles
|
|
if "axis_popup" not in shown_bundles:
|
|
shown_bundles.append("axis_popup")
|
|
self.toolbar.show_bundles(shown_bundles)
|
|
self.side_panel.hide()
|
|
|
|
elif mode == UIMode.SIDE:
|
|
shown_bundles = self.toolbar.shown_bundles
|
|
if "axis_popup" in shown_bundles:
|
|
shown_bundles.remove("axis_popup")
|
|
self.toolbar.show_bundles(shown_bundles)
|
|
pb_connection = self.toolbar.bundles["axis_popup"].get_connection("plot_base")
|
|
if pb_connection.axis_settings_dialog is not None:
|
|
pb_connection.axis_settings_dialog.close()
|
|
pb_connection.axis_settings_dialog = None
|
|
self.add_side_menus()
|
|
self.side_panel.show()
|
|
|
|
@SafeProperty(bool, doc="Enable popups setting dialogs for the plot widget.")
|
|
def enable_popups(self):
|
|
"""
|
|
Enable popups setting dialogs for the plot widget.
|
|
"""
|
|
return self.ui_mode == UIMode.POPUP
|
|
|
|
@enable_popups.setter
|
|
def enable_popups(self, value: bool):
|
|
"""
|
|
Set the popups setting dialogs for the plot widget.
|
|
|
|
Args:
|
|
value(bool): The value to set.
|
|
"""
|
|
if value:
|
|
self.ui_mode = UIMode.POPUP
|
|
else:
|
|
if self.ui_mode == UIMode.POPUP:
|
|
self.ui_mode = UIMode.NONE
|
|
|
|
@SafeProperty(bool, doc="Show Side Panel")
|
|
def enable_side_panel(self) -> bool:
|
|
"""
|
|
Show Side Panel
|
|
"""
|
|
return self.ui_mode == UIMode.SIDE
|
|
|
|
@enable_side_panel.setter
|
|
def enable_side_panel(self, value: bool):
|
|
"""
|
|
Show Side Panel
|
|
|
|
Args:
|
|
value(bool): The value to set.
|
|
"""
|
|
if value:
|
|
self.ui_mode = UIMode.SIDE
|
|
else:
|
|
if self.ui_mode == UIMode.SIDE:
|
|
self.ui_mode = UIMode.NONE
|
|
|
|
@SafeProperty(bool, doc="Show Toolbar")
|
|
def enable_toolbar(self) -> bool:
|
|
"""
|
|
Show Toolbar.
|
|
"""
|
|
return self.toolbar.isVisible()
|
|
|
|
@enable_toolbar.setter
|
|
def enable_toolbar(self, value: bool):
|
|
"""
|
|
Show Toolbar.
|
|
|
|
Args:
|
|
value(bool): The value to set.
|
|
"""
|
|
self.toolbar.setVisible(value)
|
|
|
|
@SafeProperty(bool, doc="Enable the FPS monitor.")
|
|
def enable_fps_monitor(self) -> bool:
|
|
"""
|
|
Enable the FPS monitor.
|
|
"""
|
|
return self.fps_label.isVisible()
|
|
|
|
@enable_fps_monitor.setter
|
|
def enable_fps_monitor(self, value: bool):
|
|
"""
|
|
Enable the FPS monitor.
|
|
|
|
Args:
|
|
value(bool): The value to set.
|
|
"""
|
|
if value and self.fps_monitor is None:
|
|
self.hook_fps_monitor()
|
|
elif not value and self.fps_monitor is not None:
|
|
self.unhook_fps_monitor()
|
|
|
|
################################################################################
|
|
# ViewBox State Signals
|
|
################################################################################
|
|
|
|
def viewbox_state_changed(self):
|
|
"""
|
|
Emit a signal when the state of the viewbox has changed.
|
|
Merges the default pyqtgraphs signal states and also CTRL menu toggles.
|
|
"""
|
|
|
|
viewbox_state = self.plot_item.vb.getState()
|
|
# Range Limits
|
|
x_min, x_max = viewbox_state["targetRange"][0]
|
|
y_min, y_max = viewbox_state["targetRange"][1]
|
|
self.property_changed.emit("x_min", x_min)
|
|
self.property_changed.emit("x_max", x_max)
|
|
self.property_changed.emit("y_min", y_min)
|
|
self.property_changed.emit("y_max", y_max)
|
|
|
|
# Grid Toggles
|
|
|
|
################################################################################
|
|
# Plot Properties
|
|
################################################################################
|
|
|
|
def set(self, **kwargs):
|
|
"""
|
|
Set the properties of the plot widget.
|
|
|
|
Args:
|
|
**kwargs: Keyword arguments for the properties to be set.
|
|
|
|
Possible properties:
|
|
- title: str
|
|
- x_label: str
|
|
- y_label: str
|
|
- x_scale: Literal["linear", "log"]
|
|
- y_scale: Literal["linear", "log"]
|
|
- x_lim: tuple
|
|
- y_lim: tuple
|
|
- legend_label_size: int
|
|
|
|
"""
|
|
property_map = {
|
|
"title": self.title,
|
|
"x_label": self.x_label,
|
|
"y_label": self.y_label,
|
|
"x_limits": self.x_limits,
|
|
"y_limits": self.y_limits,
|
|
"x_grid": self.x_grid,
|
|
"y_grid": self.y_grid,
|
|
"inner_axes": self.inner_axes,
|
|
"outer_axes": self.outer_axes,
|
|
"lock_aspect_ratio": self.lock_aspect_ratio,
|
|
"auto_range_x": self.auto_range_x,
|
|
"auto_range_y": self.auto_range_y,
|
|
"x_log": self.x_log,
|
|
"y_log": self.y_log,
|
|
"legend_label_size": self.legend_label_size,
|
|
}
|
|
|
|
for key, value in kwargs.items():
|
|
if key in property_map:
|
|
setattr(self, key, value)
|
|
else:
|
|
logger.warning(f"Property {key} not found.")
|
|
|
|
@SafeProperty(str, doc="The title of the axes.")
|
|
def title(self) -> str:
|
|
"""
|
|
Set title of the plot.
|
|
"""
|
|
return self.plot_item.titleLabel.text
|
|
|
|
@title.setter
|
|
def title(self, value: str):
|
|
"""
|
|
Set title of the plot.
|
|
|
|
Args:
|
|
value(str): The title to set.
|
|
"""
|
|
self.plot_item.setTitle(value)
|
|
self.property_changed.emit("title", value)
|
|
|
|
@SafeProperty(str, doc="The text of the x label")
|
|
def x_label(self) -> str:
|
|
"""
|
|
The set label for the x-axis.
|
|
"""
|
|
return self._user_x_label
|
|
|
|
@x_label.setter
|
|
def x_label(self, value: str):
|
|
"""
|
|
The set label for the x-axis.
|
|
|
|
Args:
|
|
value(str): The label to set.
|
|
"""
|
|
self._user_x_label = value
|
|
self._apply_x_label()
|
|
self.property_changed.emit("x_label", self._user_x_label)
|
|
|
|
@property
|
|
def x_label_suffix(self) -> str:
|
|
"""
|
|
A read-only (or internal) suffix automatically appended to the user label.
|
|
Not settable by the user directly from the UI.
|
|
"""
|
|
return self._x_label_suffix
|
|
|
|
def set_x_label_suffix(self, suffix: str):
|
|
"""
|
|
Public or protected method to update the suffix.
|
|
The user code or subclass (Waveform) can call this
|
|
when x_mode changes, but the AxisSettings won't show it.
|
|
"""
|
|
self._x_label_suffix = suffix
|
|
self._apply_x_label()
|
|
|
|
@property
|
|
def x_label_units(self) -> str:
|
|
"""
|
|
The units of the x-axis.
|
|
"""
|
|
return self._x_axis_units
|
|
|
|
@x_label_units.setter
|
|
def x_label_units(self, units: str):
|
|
"""
|
|
The units of the x-axis.
|
|
|
|
Args:
|
|
units(str): The units to set.
|
|
"""
|
|
self._x_axis_units = units
|
|
self._apply_x_label()
|
|
|
|
@property
|
|
def x_label_combined(self) -> str:
|
|
"""
|
|
The final label shown on the axis = user portion + suffix + [units].
|
|
"""
|
|
units = f" [{self._x_axis_units}]" if self._x_axis_units else ""
|
|
return self._user_x_label + self._x_label_suffix + units
|
|
|
|
def _apply_x_label(self):
|
|
"""
|
|
Actually updates the pyqtgraph axis label text to
|
|
the combined label. Called whenever user label or suffix changes.
|
|
"""
|
|
final_label = self.x_label_combined
|
|
if self.plot_item.getAxis("bottom").isVisible():
|
|
self.plot_item.setLabel("bottom", text=final_label)
|
|
|
|
@SafeProperty(str, doc="The text of the y label")
|
|
def y_label(self) -> str:
|
|
"""
|
|
The set label for the y-axis.
|
|
"""
|
|
return self._user_y_label
|
|
|
|
@y_label.setter
|
|
def y_label(self, value: str):
|
|
"""
|
|
The set label for the y-axis.
|
|
Args:
|
|
value(str): The label to set.
|
|
"""
|
|
self._user_y_label = value
|
|
self._apply_y_label()
|
|
self.property_changed.emit("y_label", value)
|
|
|
|
@property
|
|
def y_label_suffix(self) -> str:
|
|
"""
|
|
A read-only suffix automatically appended to the y label.
|
|
"""
|
|
return self._y_label_suffix
|
|
|
|
def set_y_label_suffix(self, suffix: str):
|
|
"""
|
|
Public method to update the y label suffix.
|
|
"""
|
|
self._y_label_suffix = suffix
|
|
self._apply_y_label()
|
|
|
|
@property
|
|
def y_label_units(self) -> str:
|
|
"""
|
|
The units of the y-axis.
|
|
"""
|
|
return self._y_axis_units
|
|
|
|
@y_label_units.setter
|
|
def y_label_units(self, units: str):
|
|
"""
|
|
The units of the y-axis.
|
|
|
|
Args:
|
|
units(str): The units to set.
|
|
"""
|
|
self._y_axis_units = units
|
|
self._apply_y_label()
|
|
|
|
@property
|
|
def y_label_combined(self) -> str:
|
|
"""
|
|
The final y label shown on the axis = user portion + suffix + [units].
|
|
"""
|
|
units = f" [{self._y_axis_units}]" if self._y_axis_units else ""
|
|
return self._user_y_label + self._y_label_suffix + units
|
|
|
|
def _apply_y_label(self):
|
|
"""
|
|
Actually updates the pyqtgraph y axis label text to
|
|
the combined y label. Called whenever y label or suffix changes.
|
|
"""
|
|
final_label = self.y_label_combined
|
|
if self.plot_item.getAxis("bottom").isVisible():
|
|
self.plot_item.setLabel("left", text=final_label)
|
|
|
|
def _tuple_to_qpointf(self, tuple: tuple | list):
|
|
"""
|
|
Helper function to convert a tuple to a QPointF.
|
|
|
|
Args:
|
|
tuple(tuple|list): Tuple or list of two numbers.
|
|
|
|
Returns:
|
|
QPointF: The tuple converted to a QPointF.
|
|
"""
|
|
if len(tuple) != 2:
|
|
raise ValueError("Limits must be a tuple or list of two numbers.")
|
|
min_val, max_val = tuple
|
|
if not isinstance(min_val, (int, float)) or not isinstance(max_val, (int, float)):
|
|
raise TypeError("Limits must be numbers.")
|
|
if min_val > max_val:
|
|
raise ValueError("Minimum limit cannot be greater than maximum limit.")
|
|
return QPoint(*tuple)
|
|
|
|
################################################################################
|
|
# X limits, has to be SaveProperty("QPointF") because of the tuple conversion for designer,
|
|
# the python properties are used for CLI and API for context dialog settings.
|
|
|
|
@SafeProperty("QPointF")
|
|
def x_limits(self) -> QPointF:
|
|
"""
|
|
Get the x limits of the plot.
|
|
"""
|
|
current_lim = self.plot_item.vb.viewRange()[0]
|
|
return QPointF(current_lim[0], current_lim[1])
|
|
|
|
@x_limits.setter
|
|
def x_limits(self, value):
|
|
"""
|
|
Set the x limits of the plot.
|
|
|
|
Args:
|
|
value(QPointF|tuple|list): The x limits to set.
|
|
"""
|
|
if isinstance(value, (tuple, list)):
|
|
value = self._tuple_to_qpointf(value)
|
|
self.plot_item.vb.setXRange(value.x(), value.y(), padding=0)
|
|
|
|
@property
|
|
def x_lim(self) -> tuple:
|
|
"""
|
|
Get the x limits of the plot.
|
|
"""
|
|
return (self.x_limits.x(), self.x_limits.y())
|
|
|
|
@x_lim.setter
|
|
def x_lim(self, value):
|
|
"""
|
|
Set the x limits of the plot.
|
|
|
|
Args:
|
|
value(tuple): The x limits to set.
|
|
"""
|
|
self.x_limits = value
|
|
|
|
@property
|
|
def x_min(self) -> float:
|
|
"""
|
|
Get the minimum x limit of the plot.
|
|
|
|
"""
|
|
return self.x_limits.x()
|
|
|
|
@x_min.setter
|
|
def x_min(self, value: float):
|
|
"""
|
|
Set the minimum x limit of the plot.
|
|
|
|
Args:
|
|
value(float): The minimum x limit to set.
|
|
"""
|
|
self.x_limits = (value, self.x_lim[1])
|
|
|
|
@property
|
|
def x_max(self) -> float:
|
|
"""
|
|
Get the maximum x limit of the plot.
|
|
"""
|
|
return self.x_limits.y()
|
|
|
|
@x_max.setter
|
|
def x_max(self, value: float):
|
|
"""
|
|
Set the maximum x limit of the plot.
|
|
|
|
Args:
|
|
value(float): The maximum x limit to set.
|
|
"""
|
|
self.x_limits = (self.x_lim[0], value)
|
|
|
|
################################################################################
|
|
# Y limits, has to be SaveProperty("QPointF") because of the tuple conversion for designer,
|
|
# the python properties are used for CLI and API for context dialog settings.
|
|
|
|
@SafeProperty("QPointF")
|
|
def y_limits(self) -> QPointF:
|
|
"""
|
|
Get the y limits of the plot.
|
|
"""
|
|
current_lim = self.plot_item.vb.viewRange()[1]
|
|
return QPointF(current_lim[0], current_lim[1])
|
|
|
|
@y_limits.setter
|
|
def y_limits(self, value):
|
|
"""
|
|
Set the y limits of the plot.
|
|
|
|
Args:
|
|
value(QPointF|tuple|list): The y limits to set.
|
|
"""
|
|
if isinstance(value, (tuple, list)):
|
|
value = self._tuple_to_qpointf(value)
|
|
self.plot_item.vb.setYRange(value.x(), value.y(), padding=0)
|
|
|
|
@property
|
|
def y_lim(self) -> tuple:
|
|
"""
|
|
Get the y limits of the plot.
|
|
"""
|
|
return (self.y_limits.x(), self.y_limits.y())
|
|
|
|
@y_lim.setter
|
|
def y_lim(self, value):
|
|
"""
|
|
Set the y limits of the plot.
|
|
|
|
Args:
|
|
value(tuple): The y limits to set.
|
|
"""
|
|
self.y_limits = value
|
|
|
|
@property
|
|
def y_min(self) -> float:
|
|
"""
|
|
Get the minimum y limit of the plot.
|
|
"""
|
|
return self.y_limits.x()
|
|
|
|
@y_min.setter
|
|
def y_min(self, value: float):
|
|
"""
|
|
Set the minimum y limit of the plot.
|
|
|
|
Args:
|
|
value(float): The minimum y limit to set.
|
|
"""
|
|
self.y_limits = (value, self.y_lim[1])
|
|
|
|
@property
|
|
def y_max(self) -> float:
|
|
"""
|
|
Get the maximum y limit of the plot.
|
|
"""
|
|
return self.y_limits.y()
|
|
|
|
@y_max.setter
|
|
def y_max(self, value: float):
|
|
"""
|
|
Set the maximum y limit of the plot.
|
|
|
|
Args:
|
|
value(float): The maximum y limit to set.
|
|
"""
|
|
self.y_limits = (self.y_lim[0], value)
|
|
|
|
@SafeProperty(bool, doc="Show grid on the x-axis.")
|
|
def x_grid(self) -> bool:
|
|
"""
|
|
Show grid on the x-axis.
|
|
"""
|
|
return self.plot_item.ctrl.xGridCheck.isChecked()
|
|
|
|
@x_grid.setter
|
|
def x_grid(self, value: bool):
|
|
"""
|
|
Show grid on the x-axis.
|
|
|
|
Args:
|
|
value(bool): The value to set.
|
|
"""
|
|
self.plot_item.showGrid(x=value)
|
|
self.property_changed.emit("x_grid", value)
|
|
|
|
@SafeProperty(bool, doc="Show grid on the y-axis.")
|
|
def y_grid(self) -> bool:
|
|
"""
|
|
Show grid on the y-axis.
|
|
"""
|
|
return self.plot_item.ctrl.yGridCheck.isChecked()
|
|
|
|
@y_grid.setter
|
|
def y_grid(self, value: bool):
|
|
"""
|
|
Show grid on the y-axis.
|
|
|
|
Args:
|
|
value(bool): The value to set.
|
|
"""
|
|
self.plot_item.showGrid(y=value)
|
|
self.property_changed.emit("y_grid", value)
|
|
|
|
@SafeProperty(bool, doc="Set X-axis to log scale if True, linear if False.")
|
|
def x_log(self) -> bool:
|
|
"""
|
|
Set X-axis to log scale if True, linear if False.
|
|
"""
|
|
return bool(self.plot_item.vb.state.get("logMode", [False, False])[0])
|
|
|
|
@x_log.setter
|
|
def x_log(self, value: bool):
|
|
"""
|
|
Set X-axis to log scale if True, linear if False.
|
|
|
|
Args:
|
|
value(bool): The value to set.
|
|
"""
|
|
self.plot_item.setLogMode(x=value)
|
|
self.property_changed.emit("x_log", value)
|
|
|
|
@SafeProperty(bool, doc="Set Y-axis to log scale if True, linear if False.")
|
|
def y_log(self) -> bool:
|
|
"""
|
|
Set Y-axis to log scale if True, linear if False.
|
|
"""
|
|
return bool(self.plot_item.vb.state.get("logMode", [False, False])[1])
|
|
|
|
@y_log.setter
|
|
def y_log(self, value: bool):
|
|
"""
|
|
Set Y-axis to log scale if True, linear if False.
|
|
|
|
Args:
|
|
value(bool): The value to set.
|
|
"""
|
|
self.plot_item.setLogMode(y=value)
|
|
self.property_changed.emit("y_log", value)
|
|
|
|
@SafeProperty(bool, doc="Show the outer axes of the plot widget.")
|
|
def outer_axes(self) -> bool:
|
|
"""
|
|
Show the outer axes of the plot widget.
|
|
"""
|
|
return self.plot_item.getAxis("top").isVisible()
|
|
|
|
@outer_axes.setter
|
|
def outer_axes(self, value: bool):
|
|
"""
|
|
Show the outer axes of the plot widget.
|
|
|
|
Args:
|
|
value(bool): The value to set.
|
|
"""
|
|
self.plot_item.showAxis("top", value)
|
|
self.plot_item.showAxis("right", value)
|
|
|
|
self.property_changed.emit("outer_axes", value)
|
|
|
|
@SafeProperty(bool, doc="Show inner axes of the plot widget.")
|
|
def inner_axes(self) -> bool:
|
|
"""
|
|
Show inner axes of the plot widget.
|
|
"""
|
|
return self.plot_item.getAxis("bottom").isVisible()
|
|
|
|
@inner_axes.setter
|
|
def inner_axes(self, value: bool):
|
|
"""
|
|
Show inner axes of the plot widget.
|
|
|
|
Args:
|
|
value(bool): The value to set.
|
|
"""
|
|
self.plot_item.showAxis("bottom", value)
|
|
self.plot_item.showAxis("left", value)
|
|
|
|
self._apply_x_label()
|
|
self._apply_y_label()
|
|
self.property_changed.emit("inner_axes", value)
|
|
|
|
@SafeProperty(bool, doc="Lock aspect ratio of the plot widget.")
|
|
def lock_aspect_ratio(self) -> bool:
|
|
"""
|
|
Lock aspect ratio of the plot widget.
|
|
"""
|
|
return bool(self.plot_item.vb.getState()["aspectLocked"])
|
|
|
|
@lock_aspect_ratio.setter
|
|
def lock_aspect_ratio(self, value: bool):
|
|
"""
|
|
Lock aspect ratio of the plot widget.
|
|
|
|
Args:
|
|
value(bool): The value to set.
|
|
"""
|
|
self.plot_item.setAspectLocked(value)
|
|
|
|
@SafeProperty(bool, doc="Set auto range for the x-axis.")
|
|
def auto_range_x(self) -> bool:
|
|
"""
|
|
Set auto range for the x-axis.
|
|
"""
|
|
return bool(self.plot_item.vb.getState()["autoRange"][0])
|
|
|
|
@auto_range_x.setter
|
|
def auto_range_x(self, value: bool):
|
|
"""
|
|
Set auto range for the x-axis.
|
|
|
|
Args:
|
|
value(bool): The value to set.
|
|
"""
|
|
self.plot_item.enableAutoRange(x=value)
|
|
|
|
@SafeProperty(bool, doc="Set auto range for the y-axis.")
|
|
def auto_range_y(self) -> bool:
|
|
"""
|
|
Set auto range for the y-axis.
|
|
"""
|
|
return bool(self.plot_item.vb.getState()["autoRange"][1])
|
|
|
|
@auto_range_y.setter
|
|
def auto_range_y(self, value: bool):
|
|
"""
|
|
Set auto range for the y-axis.
|
|
|
|
Args:
|
|
value(bool): The value to set.
|
|
"""
|
|
self.plot_item.enableAutoRange(y=value)
|
|
|
|
def auto_range(self, value: bool = True):
|
|
"""
|
|
On demand apply autorange to the plot item based on the visible curves.
|
|
|
|
Args:
|
|
value(bool): If True, apply autorange to the visible curves.
|
|
"""
|
|
if not value:
|
|
self.plot_item.enableAutoRange(x=False, y=False)
|
|
return
|
|
self._apply_autorange_only_visible_curves()
|
|
|
|
@property
|
|
def visible_items(self):
|
|
crosshair_items = []
|
|
if self.crosshair:
|
|
crosshair_items = [
|
|
self.crosshair.v_line,
|
|
self.crosshair.h_line,
|
|
self.crosshair.coord_label,
|
|
]
|
|
return [
|
|
item
|
|
for item in self.plot_item.items
|
|
if item.isVisible() and item not in crosshair_items
|
|
]
|
|
|
|
def _apply_autorange_only_visible_curves(self):
|
|
"""
|
|
Apply autorange to the plot item based on the provided curves.
|
|
|
|
Args:
|
|
curves (list): List of curves to apply autorange to.
|
|
"""
|
|
visible_items = self.visible_items
|
|
|
|
self.plot_item.autoRange(items=visible_items if visible_items else None)
|
|
|
|
@SafeProperty(int, doc="The font size of the legend font.")
|
|
def legend_label_size(self) -> int:
|
|
"""
|
|
The font size of the legend font.
|
|
"""
|
|
if not self.plot_item.legend:
|
|
return
|
|
scale = self.plot_item.legend.scale() * 9
|
|
return scale
|
|
|
|
@legend_label_size.setter
|
|
def legend_label_size(self, value: int):
|
|
"""
|
|
The font size of the legend font.
|
|
|
|
Args:
|
|
value(int): The font size to set.
|
|
"""
|
|
if not self.plot_item.legend:
|
|
return
|
|
scale = (
|
|
value / 9
|
|
) # 9 is the default font size of the legend, so we always scale it against 9
|
|
self.plot_item.legend.setScale(scale)
|
|
|
|
################################################################################
|
|
# FPS Counter
|
|
################################################################################
|
|
|
|
def update_fps_label(self, fps: float) -> None:
|
|
"""
|
|
Update the FPS label.
|
|
|
|
Args:
|
|
fps(float): The frames per second.
|
|
"""
|
|
if self.fps_label:
|
|
self.fps_label.setText(f"FPS: {fps:.2f}")
|
|
|
|
def hook_fps_monitor(self):
|
|
"""Hook the FPS monitor to the plot."""
|
|
if self.fps_monitor is None:
|
|
self.fps_monitor = FPSCounter(self.plot_item.vb)
|
|
self.fps_label.show()
|
|
|
|
self.fps_monitor.sigFpsUpdate.connect(self.update_fps_label)
|
|
self.update_fps_label(0)
|
|
|
|
def unhook_fps_monitor(self, delete_label=True):
|
|
"""Unhook the FPS monitor from the plot."""
|
|
if self.fps_monitor is not None and delete_label:
|
|
# Remove Monitor
|
|
self.fps_monitor.cleanup()
|
|
self.fps_monitor.deleteLater()
|
|
self.fps_monitor = None
|
|
if self.fps_label is not None:
|
|
# Hide Label
|
|
self.fps_label.hide()
|
|
|
|
################################################################################
|
|
# Crosshair
|
|
################################################################################
|
|
|
|
def hook_crosshair(self) -> None:
|
|
"""Hook the crosshair to all plots."""
|
|
if self.crosshair is None:
|
|
self.crosshair = Crosshair(
|
|
self.plot_item, min_precision=self._minimal_crosshair_precision
|
|
)
|
|
self.crosshair.crosshairChanged.connect(self.crosshair_position_changed)
|
|
self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked)
|
|
self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed)
|
|
self.crosshair.coordinatesClicked1D.connect(self.crosshair_coordinates_clicked)
|
|
self.crosshair.coordinatesChanged2D.connect(self.crosshair_coordinates_changed)
|
|
self.crosshair.coordinatesClicked2D.connect(self.crosshair_coordinates_clicked)
|
|
|
|
def unhook_crosshair(self) -> None:
|
|
"""Unhook the crosshair from all plots."""
|
|
if self.crosshair is not None:
|
|
self.crosshair.crosshairChanged.disconnect(self.crosshair_position_changed)
|
|
self.crosshair.crosshairClicked.disconnect(self.crosshair_position_clicked)
|
|
self.crosshair.coordinatesChanged1D.disconnect(self.crosshair_coordinates_changed)
|
|
self.crosshair.coordinatesClicked1D.disconnect(self.crosshair_coordinates_clicked)
|
|
self.crosshair.coordinatesChanged2D.disconnect(self.crosshair_coordinates_changed)
|
|
self.crosshair.coordinatesClicked2D.disconnect(self.crosshair_coordinates_clicked)
|
|
self.crosshair.cleanup()
|
|
self.crosshair.deleteLater()
|
|
self.crosshair = None
|
|
|
|
def toggle_crosshair(self) -> None:
|
|
"""Toggle the crosshair on all plots."""
|
|
if self.crosshair is None:
|
|
return self.hook_crosshair()
|
|
|
|
self.unhook_crosshair()
|
|
|
|
@SafeProperty(
|
|
int, doc="Minimum decimal places for crosshair when dynamic precision is enabled."
|
|
)
|
|
def minimal_crosshair_precision(self) -> int:
|
|
"""
|
|
Minimum decimal places for crosshair when dynamic precision is enabled.
|
|
"""
|
|
return self._minimal_crosshair_precision
|
|
|
|
@minimal_crosshair_precision.setter
|
|
def minimal_crosshair_precision(self, value: int):
|
|
"""
|
|
Set the minimum decimal places for crosshair when dynamic precision is enabled.
|
|
|
|
Args:
|
|
value(int): The minimum decimal places to set.
|
|
"""
|
|
value_int = max(0, int(value))
|
|
self._minimal_crosshair_precision = value_int
|
|
if self.crosshair is not None:
|
|
self.crosshair.min_precision = value_int
|
|
self.property_changed.emit("minimal_crosshair_precision", value_int)
|
|
|
|
@SafeSlot()
|
|
def reset(self) -> None:
|
|
"""Reset the plot widget."""
|
|
if self.crosshair is not None:
|
|
self.crosshair.clear_markers()
|
|
self.crosshair.update_markers()
|
|
|
|
def cleanup(self):
|
|
self.toolbar.cleanup()
|
|
self.unhook_crosshair()
|
|
self.unhook_fps_monitor(delete_label=True)
|
|
self.tick_item.cleanup()
|
|
self.arrow_item.cleanup()
|
|
if self.axis_settings_dialog is not None:
|
|
self.axis_settings_dialog.close()
|
|
self.axis_settings_dialog = None
|
|
self.cleanup_pyqtgraph()
|
|
self.round_plot_widget.close()
|
|
super().cleanup()
|
|
|
|
def cleanup_pyqtgraph(self, item: pg.PlotItem | None = None):
|
|
"""Cleanup pyqtgraph items."""
|
|
if item is None:
|
|
item = self.plot_item
|
|
item.vb.menu.close()
|
|
item.vb.menu.deleteLater()
|
|
item.ctrlMenu.close()
|
|
item.ctrlMenu.deleteLater()
|
|
|
|
|
|
class DemoPlotBase(QMainWindow): # pragma: no cover:
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.main_widget = QWidget()
|
|
self.setCentralWidget(self.main_widget)
|
|
self.main_widget.layout = QHBoxLayout(self.main_widget)
|
|
|
|
self.plot_popup = PlotBase(popups=True)
|
|
self.plot_popup.title = "PlotBase with popups"
|
|
self.plot_side_panels = PlotBase(popups=False)
|
|
self.plot_side_panels.title = "PlotBase with side panels"
|
|
|
|
self.plot_popup.plot_item.plot(np.random.rand(100), pen=(255, 0, 0))
|
|
self.plot_side_panels.plot_item.plot(np.random.rand(100), pen=(0, 255, 0))
|
|
|
|
self.main_widget.layout.addWidget(self.plot_side_panels)
|
|
self.main_widget.layout.addWidget(self.plot_popup)
|
|
|
|
self.resize(1400, 600)
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover:
|
|
import sys
|
|
|
|
from qtpy.QtWidgets import QApplication
|
|
|
|
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
|
|
|
app = QApplication(sys.argv)
|
|
launch_window = BECMainWindow()
|
|
pb = PlotBase(popups=False)
|
|
launch_window.setCentralWidget(pb)
|
|
launch_window.show()
|
|
|
|
sys.exit(app.exec_())
|