# /*##########################################################################
#
# Copyright (c) 2016-2023 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""This module provides a widget displaying mouse coordinates in a PlotWidget.
It can be configured to provide more information.
"""
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
__date__ = "16/10/2017"
import logging
import numbers
import traceback
import weakref
import numpy
from ... import qt
from .. import items
from ...widgets.ElidedLabel import ElidedLabel
_logger = logging.getLogger(__name__)
class _PositionInfoLabel(ElidedLabel):
"""QLabel with a default size larger than what is displayed."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setTextInteractionFlags(qt.Qt.TextSelectableByMouse)
def sizeHint(self):
hint = super().sizeHint()
width = self.fontMetrics().boundingRect("##############").width()
return qt.QSize(max(hint.width(), width), hint.height())
# PositionInfo ################################################################
[docs]
class PositionInfo(qt.QWidget):
"""QWidget displaying coords converted from data coords of the mouse.
Provide this widget with a list of couple:
- A name to display before the data
- A function that takes (x, y) as arguments and returns something that
gets converted to a string.
If the result is a float it is converted with '%.7g' format.
To run the following sample code, a QApplication must be initialized.
First, create a PlotWindow and add a QToolBar where to place the
PositionInfo widget.
>>> from silx.gui.plot import PlotWindow
>>> from silx.gui import qt
>>> plot = PlotWindow() # Create a PlotWindow to add the widget to
>>> toolBar = qt.QToolBar() # Create a toolbar to place the widget in
>>> plot.addToolBar(qt.Qt.BottomToolBarArea, toolBar) # Add it to plot
Then, create the PositionInfo widget and add it to the toolbar.
The PositionInfo widget is created with a list of converters, here
to display polar coordinates of the mouse position.
>>> import numpy
>>> from silx.gui.plot.tools import PositionInfo
>>> position = PositionInfo(plot=plot, converters=[
... ('Radius', lambda x, y: numpy.sqrt(x*x + y*y)),
... ('Angle', lambda x, y: numpy.degrees(numpy.arctan2(y, x)))])
>>> toolBar.addWidget(position) # Add the widget to the toolbar
<...>
>>> plot.show() # To display the PlotWindow with the position widget
:param plot: The PlotWidget this widget is displaying data coords from.
:param converters:
List of 2-tuple: name to display and conversion function from (x, y)
in data coords to displayed value.
If None, the default, it displays X and Y.
:param parent: Parent widget
"""
SNAP_THRESHOLD_DIST = 5
def __init__(self, parent=None, plot=None, converters=None):
assert plot is not None
self._plotRef = weakref.ref(plot)
self._snappingMode = self.SNAPPING_DISABLED
super(PositionInfo, self).__init__(parent)
if converters is None:
converters = (("X", lambda x, y: x), ("Y", lambda x, y: y))
self._fields = [] # To store (QLineEdit, name, function (x, y)->v)
# Create a new layout with new widgets
layout = qt.QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
# layout.setSpacing(0)
# Create all QLabel and store them with the corresponding converter
for name, func in converters:
layout.addWidget(qt.QLabel("<b>" + name + ":</b>"))
contentWidget = _PositionInfoLabel(self)
contentWidget.setText("------")
layout.addWidget(contentWidget)
self._fields.append((contentWidget, name, func))
layout.addStretch(1)
self.setLayout(layout)
# Connect to Plot events
plot.sigPlotSignal.connect(self._plotEvent)
[docs]
def getConverters(self):
"""Return the list of converters as 2-tuple (name, function)."""
return [(name, func) for _label, name, func in self._fields]
def _plotEvent(self, event):
"""Handle events from the Plot.
:param dict event: Plot event
"""
if event["event"] == "mouseMoved":
x, y = event["x"], event["y"]
xPixel, yPixel = event["xpixel"], event["ypixel"]
self._updateStatusBar(x, y, xPixel, yPixel)
[docs]
def updateInfo(self):
"""Update displayed information"""
plot = self.getPlotWidget()
if plot is None:
_logger.error(
"Trying to update PositionInfo " "while PlotWidget no longer exists"
)
return
widget = plot.getWidgetHandle()
position = widget.mapFromGlobal(qt.QCursor.pos())
xPixel, yPixel = position.x(), position.y()
dataPos = plot.pixelToData(xPixel, yPixel, check=True)
if dataPos is not None: # Inside plot area
x, y = dataPos
self._updateStatusBar(x, y, xPixel, yPixel)
def _updateStatusBar(self, x, y, xPixel, yPixel):
"""Update information from the status bar using the definitions.
:param float x: Position-x in data
:param float y: Position-y in data
:param float xPixel: Position-x in pixels
:param float yPixel: Position-y in pixels
"""
plot = self.getPlotWidget()
if plot is None:
return
styleSheet = "" # Default style
xData, yData = x, y
snappingMode = self.getSnappingMode()
# Snapping when crosshair either not requested or active
if snappingMode & (self.SNAPPING_CURVE | self.SNAPPING_SCATTER) and (
not (snappingMode & self.SNAPPING_CROSSHAIR) or plot.getGraphCursor()
):
styleSheet = "color: rgb(255, 0, 0);" # Style far from item
if snappingMode & self.SNAPPING_ACTIVE_ONLY:
selectedItems = []
if snappingMode & self.SNAPPING_CURVE:
activeCurve = plot.getActiveCurve()
if activeCurve:
selectedItems.append(activeCurve)
if snappingMode & self.SNAPPING_SCATTER:
activeScatter = plot.getActiveScatter()
if activeScatter:
selectedItems.append(activeScatter)
else:
kinds = []
if snappingMode & self.SNAPPING_CURVE:
kinds.append(items.Curve)
kinds.append(items.Histogram)
if snappingMode & self.SNAPPING_SCATTER:
kinds.append(items.Scatter)
selectedItems = [
item
for item in plot.getItems()
if isinstance(item, tuple(kinds)) and item.isVisible()
]
# Compute distance threshold
window = plot.window()
windowHandle = window.windowHandle()
if windowHandle is not None:
ratio = windowHandle.devicePixelRatio()
else:
ratio = qt.QGuiApplication.primaryScreen().devicePixelRatio()
# Baseline squared distance threshold
sqDistInPixels = (self.SNAP_THRESHOLD_DIST * ratio) ** 2
for item in selectedItems:
if snappingMode & self.SNAPPING_SYMBOLS_ONLY and (
not isinstance(item, items.SymbolMixIn) or not item.getSymbol()
):
# Only handled if item symbols are visible
continue
if isinstance(item, items.Histogram):
result = item.pick(xPixel, yPixel)
if result is not None: # Histogram picked
index = result.getIndices()[0]
edges = item.getBinEdgesData(copy=False)
# Snap to bin center and value
xData = 0.5 * (edges[index] + edges[index + 1])
yData = item.getValueData(copy=False)[index]
# Update label style sheet
styleSheet = ""
break
else: # Curve, Scatter
result = item.pick(xPixel, yPixel)
if result is None:
continue
indices = result.getIndices(copy=False)
if indices is None:
continue
if isinstance(item, items.YAxisMixIn):
axis = item.getYAxis()
else:
axis = "left"
xArray = item.getXData(copy=False)[indices]
yArray = item.getYData(copy=False)[indices]
pixelPositions = plot.dataToPixel(xArray, yArray, axis=axis)
if pixelPositions is None:
continue
sqDistances = (pixelPositions[0] - xPixel) ** 2 + (
pixelPositions[1] - yPixel
) ** 2
if not numpy.any(numpy.isfinite(sqDistances)):
continue
closestIndex = numpy.nanargmin(sqDistances)
closestSqDistInPixels = sqDistances[closestIndex]
if closestSqDistInPixels <= sqDistInPixels:
# Update label style sheet
styleSheet = ""
# if close enough, snap to data point coord
xData, yData = xArray[closestIndex], yArray[closestIndex]
sqDistInPixels = closestSqDistInPixels
for label, name, func in self._fields:
label.setStyleSheet(styleSheet)
try:
value = func(xData, yData)
text = self.valueToString(value)
label.setText(text)
except:
label.setText("Error")
_logger.error(
"Error while converting coordinates (%f, %f)"
"with converter '%s'" % (xPixel, yPixel, name)
)
_logger.error(traceback.format_exc())
def valueToString(self, value):
if isinstance(value, (tuple, list)):
value = [self.valueToString(v) for v in value]
return ", ".join(value)
elif isinstance(value, numbers.Real):
# Use this for floats and int
return "%.7g" % value
else:
# Fallback for other types
return str(value)
# Snapping mode
SNAPPING_DISABLED = 0
"""No snapping occurs"""
SNAPPING_CROSSHAIR = 1 << 0
"""Snapping only enabled when crosshair cursor is enabled"""
SNAPPING_ACTIVE_ONLY = 1 << 1
"""Snapping only enabled for active item"""
SNAPPING_SYMBOLS_ONLY = 1 << 2
"""Snapping only when symbols are visible"""
SNAPPING_CURVE = 1 << 3
"""Snapping on curves"""
SNAPPING_SCATTER = 1 << 4
"""Snapping on scatter"""
[docs]
def setSnappingMode(self, mode):
"""Set the snapping mode.
The mode is a mask.
:param int mode: The mode to use
"""
if mode != self._snappingMode:
self._snappingMode = mode
self.updateInfo()
[docs]
def getSnappingMode(self):
"""Returns the snapping mode as a mask
:rtype: int
"""
return self._snappingMode