Improve target item - incorporate bits from PR 313 (#1318)

Overhaul TargetItem based on @lesauxvi 's PR #313
This commit is contained in:
Ogi Moore 2021-04-10 22:42:44 -07:00 committed by GitHub
parent bb90ef1ec9
commit 5a08650853
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 528 additions and 146 deletions

46
.flake8
View File

@ -3,47 +3,5 @@ exclude = .git,.tox,__pycache__,doc,old,build,dist
show_source = True show_source = True
statistics = True statistics = True
verbose = 2 verbose = 2
select = max-line-length = 88
E101, extend-ignore = E203, W503
E112,
E122,
E125,
E133,
E223,
E224,
E242,
E273,
E274,
E901,
E902,
W191,
W601,
W602,
W603,
W604,
E124,
E231,
E211,
E261,
E271,
E272,
E304,
F401,
F402,
F403,
F404,
E501,
E502,
E702,
E703,
E711,
E712,
E721,
F811,
F812,
F821,
F822,
F823,
F831,
F841,
W292

View File

@ -46,3 +46,4 @@ Contents:
uigraphicsitem uigraphicsitem
graphicswidgetanchor graphicswidgetanchor
dateaxisitem dateaxisitem
targetitem

View File

@ -0,0 +1,17 @@
TargetItem
==========
.. autoclass:: pyqtgraph.TargetItem
:members:
.. automethod:: pyqtgraph.TargetItem.__init__
TargetLabel
==================
.. autoclass:: pyqtgraph.TargetLabel
:members:
.. automethod:: pyqtgraph.TargetLabel.__init__

View File

@ -32,6 +32,53 @@ p1.addItem(inf1)
p1.addItem(inf2) p1.addItem(inf2)
p1.addItem(inf3) p1.addItem(inf3)
targetItem1 = pg.TargetItem(
label=True,
symbol="crosshair",
labelOpts={
"angle": 0
}
)
targetItem2 = pg.TargetItem(
pos=(30, 5),
size=20,
label="vert={1:0.2f}",
symbol="star",
pen="#F4511E",
labelOpts={
"angle": 45,
"offset": QtCore.QPoint(15, 15)
}
)
targetItem3 = pg.TargetItem(
pos=(10, 10),
size=10,
label="Third Label",
symbol="x",
pen="#00ACC1",
labelOpts={
"anchor": QtCore.QPointF(0.5, 0.5),
"offset": QtCore.QPointF(30, 0),
"color": "#558B2F",
"rotateAxis": (0, 1)
}
)
def callableFunction(x, y):
return f"Square Values: ({x**2:.4f}, {y**2:.4f})"
targetItem4 = pg.TargetItem(
pos=(10, -10),
label=callableFunction
)
p1.addItem(targetItem1)
p1.addItem(targetItem2)
p1.addItem(targetItem3)
p1.addItem(targetItem4)
# Add a linear region with a label # Add a linear region with a label
lr = pg.LinearRegionItem(values=[70, 80]) lr = pg.LinearRegionItem(values=[70, 80])
p1.addItem(lr) p1.addItem(lr)

View File

@ -32,6 +32,7 @@ examples = OrderedDict([
('GraphicsItems', OrderedDict([ ('GraphicsItems', OrderedDict([
('Scatter Plot', 'ScatterPlot.py'), ('Scatter Plot', 'ScatterPlot.py'),
#('PlotItem', 'PlotItem.py'), #('PlotItem', 'PlotItem.py'),
('InfiniteLine', 'InfiniteLine.py'),
('IsocurveItem', 'isocurve.py'), ('IsocurveItem', 'isocurve.py'),
('GraphItem', 'GraphItem.py'), ('GraphItem', 'GraphItem.py'),
('ErrorBarItem', 'ErrorBarItem.py'), ('ErrorBarItem', 'ErrorBarItem.py'),

View File

@ -232,6 +232,7 @@ from .graphicsItems.FillBetweenItem import *
from .graphicsItems.LegendItem import * from .graphicsItems.LegendItem import *
from .graphicsItems.ScatterPlotItem import * from .graphicsItems.ScatterPlotItem import *
from .graphicsItems.ItemGroup import * from .graphicsItems.ItemGroup import *
from .graphicsItems.TargetItem import *
from .widgets.MultiPlotWidget import * from .widgets.MultiPlotWidget import *
from .widgets.ScatterPlotWidget import * from .widgets.ScatterPlotWidget import *

View File

@ -39,10 +39,22 @@ _USE_QRECT = QT_LIB not in ['PySide2', 'PySide6']
## Build all symbol paths ## Build all symbol paths
name_list = ['o', 's', 't', 't1', 't2', 't3', 'd', '+', 'x', 'p', 'h', 'star', name_list = ['o', 's', 't', 't1', 't2', 't3', 'd', '+', 'x', 'p', 'h', 'star',
'arrow_up', 'arrow_right', 'arrow_down', 'arrow_left'] 'arrow_up', 'arrow_right', 'arrow_down', 'arrow_left', 'crosshair']
Symbols = OrderedDict([(name, QtGui.QPainterPath()) for name in name_list]) Symbols = OrderedDict([(name, QtGui.QPainterPath()) for name in name_list])
Symbols['o'].addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1)) Symbols['o'].addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1))
Symbols['s'].addRect(QtCore.QRectF(-0.5, -0.5, 1, 1)) Symbols['s'].addRect(QtCore.QRectF(-0.5, -0.5, 1, 1))
def makeCrosshair(r=0.5, w=1, h=1):
path = QtGui.QPainterPath()
rect = QtCore.QRectF(-r, -r, r * 2, r * 2)
path.addEllipse(rect)
path.moveTo(-w, 0)
path.lineTo(w, 0)
path.moveTo(0, -h)
path.lineTo(0, h)
return path
Symbols['crosshair'] = makeCrosshair()
coords = { coords = {
't': [(-0.5, -0.5), (0, 0.5), (0.5, -0.5)], 't': [(-0.5, -0.5), (0, 0.5), (0.5, -0.5)],
't1': [(-0.5, 0.5), (0, -0.5), (0.5, 0.5)], 't1': [(-0.5, 0.5), (0, -0.5), (0.5, 0.5)],

View File

@ -1,132 +1,477 @@
from math import atan2, pi
from ..Qt import QtGui, QtCore from ..Qt import QtGui, QtCore
import numpy as np
from ..Point import Point from ..Point import Point
from .. import functions as fn from .. import functions as fn
from .GraphicsObject import GraphicsObject from .GraphicsObject import GraphicsObject
from .UIGraphicsItem import UIGraphicsItem
from .TextItem import TextItem from .TextItem import TextItem
from .ScatterPlotItem import Symbols, makeCrosshair
from .ViewBox import ViewBox
import string
import warnings
class TargetItem(GraphicsObject): class TargetItem(UIGraphicsItem):
"""Draws a draggable target symbol (circle plus crosshair). """Draws a draggable target symbol (circle plus crosshair).
The size of TargetItem will remain fixed on screen even as the view is zoomed. The size of TargetItem will remain fixed on screen even as the view is zoomed.
Includes an optional text label. Includes an optional text label.
""" """
sigDragged = QtCore.Signal(object)
def __init__(self, movable=True, radii=(5, 10, 10), pen=(255, 255, 0), brush=(0, 0, 255, 100)): sigPositionChanged = QtCore.Signal(object)
GraphicsObject.__init__(self) sigPositionChangeFinished = QtCore.Signal(object)
self._bounds = None
self._radii = radii def __init__(
self._picture = None self,
pos=None,
size=10,
radii=None,
symbol="crosshair",
pen=None,
hoverPen=None,
brush=None,
hoverBrush=None,
movable=True,
label=None,
labelOpts=None,
):
r"""
Parameters
----------
pos : list, tuple, QPointF, QPoint, Optional
Initial position of the symbol. Default is (0, 0)
size : int
Size of the symbol in pixels. Default is 10.
radii : tuple of int
Deprecated. Gives size of crosshair in screen pixels.
pen : QPen, tuple, list or str
Pen to use when drawing line. Can be any arguments that are valid
for :func:`~pyqtgraph.mkPen`. Default pen is transparent yellow.
brush : QBrush, tuple, list, or str
Defines the brush that fill the symbol. Can be any arguments that
is valid for :func:`~pyqtgraph.mkBrush`. Default is transparent
blue.
movable : bool
If True, the symbol can be dragged to a new position by the user.
hoverPen : QPen, tuple, list, or str
Pen to use when drawing symbol when hovering over it. Can be any
arguments that are valid for :func:`~pyqtgraph.mkPen`. Default pen
is red.
hoverBrush : QBrush, tuple, list or str
Brush to use to fill the symbol when hovering over it. Can be any
arguments that is valid for :func:`~pyqtgraph.mkBrush`. Default is
transparent blue.
symbol : QPainterPath or str
QPainterPath to use for drawing the target, should be centered at
``(0, 0)`` with ``max(width, height) == 1.0``. Alternatively a string
which can be any symbol accepted by
:func:`~pyqtgraph.ScatterPlotItem.setData`
label : bool, str or callable, optional
Text to be displayed in a label attached to the symbol, or None to
show no label (default is None). May optionally include formatting
strings to display the symbol value, or a callable that accepts x
and y as inputs. If True, the label is ``x = {: >.3n}\ny = {: >.3n}``
False or None will result in no text being displayed
labelOpts : dict
A dict of keyword arguments to use when constructing the text
label. See :class:`TargetLabel` and :class:`~pyqtgraph.TextItem`
"""
super().__init__(self)
self.movable = movable self.movable = movable
self.moving = False self.moving = False
self.label = None self._label = None
self.labelAngle = 0 self.mouseHovering = False
self.pen = fn.mkPen(pen)
self.brush = fn.mkBrush(brush)
def setLabel(self, label): if radii is not None:
if label is None: warnings.warn(
if self.label is not None: "'radii' is now deprecated, and will be removed in 0.13.0. Use 'size' "
self.label.scene().removeItem(self.label) "parameter instead",
self.label = None DeprecationWarning,
stacklevel=2,
)
symbol = makeCrosshair(*radii)
size = 1
if pen is None:
pen = (255, 255, 0)
self.setPen(pen)
if hoverPen is None:
hoverPen = (255, 0, 255)
self.setHoverPen(hoverPen)
if brush is None:
brush = (0, 0, 255, 50)
self.setBrush(brush)
if hoverBrush is None:
hoverBrush = (0, 255, 255, 100)
self.setHoverBrush(hoverBrush)
self.currentPen = self.pen
self.currentBrush = self.brush
self._shape = None
self._pos = Point(0, 0)
if pos is None:
pos = Point(0, 0)
self.setPos(pos)
if isinstance(symbol, str):
try:
self._path = Symbols[symbol]
except KeyError:
raise KeyError("symbol name found in available Symbols")
elif isinstance(symbol, QtGui.QPainterPath):
self._path = symbol
else: else:
if self.label is None: raise TypeError("Unknown type provided as symbol")
self.label = TextItem()
self.label.setParentItem(self)
self.label.setText(label)
self._updateLabel()
def setLabelAngle(self, angle): self.scale = size
if self.labelAngle != angle: self.setPath(self._path)
self.labelAngle = angle self.setLabel(label, labelOpts)
self._updateLabel()
@property
def sigDragged(self):
warnings.warn(
"'sigDragged' has been deprecated and will be removed in 0.13.0. Use "
"`sigPositionChanged` instead",
DeprecationWarning,
stacklevel=2,
)
return self.sigPositionChangeFinished
def setPos(self, pos):
"""Method to set the position to ``(x, y)`` within the plot view
Parameters
----------
pos : tuple, list, QPointF, QPoint, or pg.Point
Container that consists of ``(x, y)`` representation of where the
TargetItem should be placed
Raises
------
TypeError
If the type of ``pos`` does not match the known types to extract
coordinate info from, a TypeError is raised
"""
if isinstance(pos, Point):
newPos = pos
elif isinstance(pos, (tuple, list)):
newPos = Point(pos)
elif isinstance(pos, (QtCore.QPointF, QtCore.QPoint)):
newPos = Point(pos.x(), pos.y())
else:
raise TypeError
if self._pos != newPos:
self._pos = newPos
super().setPos(self._pos)
self.sigPositionChanged.emit(self)
def setBrush(self, *args, **kwargs):
"""Set the brush that fills the symbol. Allowable arguments are any that
are valid for :func:`~pyqtgraph.mkBrush`.
"""
self.brush = fn.mkBrush(*args, **kwargs)
if not self.mouseHovering:
self.currentBrush = self.brush
self.update()
def setHoverBrush(self, *args, **kwargs):
"""Set the brush that fills the symbol when hovering over it. Allowable
arguments are any that are valid for :func:`~pyqtgraph.mkBrush`.
"""
self.hoverBrush = fn.mkBrush(*args, **kwargs)
if self.mouseHovering:
self.currentBrush = self.hoverBrush
self.update()
def setPen(self, *args, **kwargs):
"""Set the pen for drawing the symbol. Allowable arguments are any that
are valid for :func:`~pyqtgraph.mkPen`."""
self.pen = fn.mkPen(*args, **kwargs)
if not self.mouseHovering:
self.currentPen = self.pen
self.update()
def setHoverPen(self, *args, **kwargs):
"""Set the pen for drawing the symbol when hovering over it. Allowable
arguments are any that are valid for
:func:`~pyqtgraph.mkPen`."""
self.hoverPen = fn.mkPen(*args, **kwargs)
if self.mouseHovering:
self.currentPen = self.hoverPen
self.update()
def boundingRect(self): def boundingRect(self):
if self._picture is None: return self.shape().boundingRect()
self._drawPicture()
return self._bounds
def dataBounds(self, axis, frac=1.0, orthoRange=None): def paint(self, p, *_):
return [0, 0] p.setPen(self.currentPen)
p.setBrush(self.currentBrush)
p.drawPath(self.shape())
def viewTransformChanged(self): def setPath(self, path):
self._picture = None if path != self._path:
self._path = path
self._shape = None
return None
def shape(self):
if self._shape is None:
s = self.generateShape()
if s is None:
return self._path
self._shape = s
# beware--this can cause the view to adjust
# which would immediately invalidate the shape.
self.prepareGeometryChange() self.prepareGeometryChange()
self._updateLabel() return self._shape
def _updateLabel(self): def generateShape(self):
if self.label is None: dt = self.deviceTransform()
return if dt is None:
self._shape = self._path
# find an optimal location for text at the given angle return None
angle = self.labelAngle * np.pi / 180. v = dt.map(QtCore.QPointF(1, 0)) - dt.map(QtCore.QPointF(0, 0))
lbr = self.label.boundingRect() dti = fn.invertQTransform(dt)
center = lbr.center() devPos = dt.map(QtCore.QPointF(0, 0))
a = abs(np.sin(angle) * lbr.height()*0.5) tr = QtGui.QTransform()
b = abs(np.cos(angle) * lbr.width()*0.5) tr.translate(devPos.x(), devPos.y())
r = max(self._radii) + 2 + max(a, b) va = atan2(v.y(), v.x())
pos = self.mapFromScene(self.mapToScene(QtCore.QPointF(0, 0)) + r * QtCore.QPointF(np.cos(angle), -np.sin(angle)) - center) tr.rotate(va * 180.0 / pi)
self.label.setPos(pos) tr.scale(self.scale, self.scale)
return dti.map(tr.map(self._path))
def paint(self, p, *args):
if self._picture is None:
self._drawPicture()
self._picture.play(p)
def _drawPicture(self):
self._picture = QtGui.QPicture()
p = QtGui.QPainter(self._picture)
p.setRenderHint(p.Antialiasing)
# Note: could do this with self.pixelLength, but this is faster.
o = self.mapToScene(QtCore.QPointF(0, 0))
dx = (self.mapToScene(QtCore.QPointF(1, 0)) - o).x()
dy = (self.mapToScene(QtCore.QPointF(0, 1)) - o).y()
if dx == 0 or dy == 0:
p.end()
self._bounds = QtCore.QRectF()
return
px = abs(1.0 / dx)
py = abs(1.0 / dy)
r, w, h = self._radii
w = w * px
h = h * py
rx = r * px
ry = r * py
rect = QtCore.QRectF(-rx, -ry, rx*2, ry*2)
p.setPen(self.pen)
p.setBrush(self.brush)
p.drawEllipse(rect)
p.drawLine(Point(-w, 0), Point(w, 0))
p.drawLine(Point(0, -h), Point(0, h))
p.end()
bx = max(w, rx)
by = max(h, ry)
self._bounds = QtCore.QRectF(-bx, -by, bx*2, by*2)
def mouseDragEvent(self, ev): def mouseDragEvent(self, ev):
if not self.movable: if not self.movable or int(ev.button() & QtCore.Qt.LeftButton) == 0:
return return
if ev.button() == QtCore.Qt.LeftButton:
if ev.isStart():
self.moving = True
self.cursorOffset = self.pos() - self.mapToParent(ev.buttonDownPos())
self.startPosition = self.pos()
ev.accept() ev.accept()
if ev.isStart():
self.symbolOffset = self.pos() - self.mapToView(ev.buttonDownPos())
self.moving = True
if not self.moving: if not self.moving:
return return
self.setPos(self.symbolOffset + self.mapToView(ev.pos()))
self.setPos(self.cursorOffset + self.mapToParent(ev.pos()))
if ev.isFinish(): if ev.isFinish():
self.moving = False self.moving = False
self.sigDragged.emit(self) self.sigPositionChangeFinished.emit(self)
def mouseClickEvent(self, ev):
if self.moving and ev.button() == QtCore.Qt.RightButton:
ev.accept()
self.moving = False
self.sigPositionChanged.emit(self)
self.sigPositionChangeFinished.emit(self)
def setMouseHover(self, hover):
# Inform the item that the mouse is(not) hovering over it
if self.mouseHovering is hover:
return
self.mouseHovering = hover
if hover:
self.currentBrush = self.hoverBrush
self.currentPen = self.hoverPen
else:
self.currentBrush = self.brush
self.currentPen = self.pen
self.update()
def hoverEvent(self, ev): def hoverEvent(self, ev):
if self.movable: if self.movable and (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton):
ev.acceptDrags(QtCore.Qt.LeftButton) self.setMouseHover(True)
else:
self.setMouseHover(False)
def viewTransformChanged(self):
GraphicsObject.viewTransformChanged(self)
self._shape = None # invalidate shape, recompute later if requested.
self.update()
def pos(self):
"""Provides the current position of the TargetItem
Returns
-------
Point
pg.Point of the current position of the TargetItem
"""
return self._pos
def label(self):
"""Provides the TargetLabel if it exists
Returns
-------
TargetLabel or None
If a TargetLabel exists for this TargetItem, return that, otherwise
return None
"""
return self._label
def setLabel(self, text=None, labelOpts=None):
"""Method to call to enable or disable the TargetLabel for displaying text
Parameters
----------
text : Callable or str, optional
Details how to format the text, by default None
If None, do not show any text next to the TargetItem
If Callable, then the label will display the result of ``text(x, y)``
If a fromatted string, then the output of ``text.format(x, y)`` will be
displayed
If a non-formatted string, then the text label will display ``text``, by
default None
labelOpts : dictionary, optional
These arguments are passed on to :class:`~pyqtgraph.TextItem`
"""
if not text:
if self._label is not None and self._label.scene() is not None:
# remove the label if it's already added
self._label.scene().removeItem(self._label)
self._label = None
else:
# provide default text if text is True
if text is True:
# convert to default value or empty string
text = "x = {: .3n}\ny = {: .3n}"
labelOpts = {} if labelOpts is None else labelOpts
if self._label is not None:
self._label.scene().removeItem(self._label)
self._label = TargetLabel(self, text=text, **labelOpts)
def setLabelAngle(self, angle):
warnings.warn(
"TargetItem.setLabelAngle is deprecated and will be removed in 0.13.0."
"Use TargetItem.label().setAngle() instead",
DeprecationWarning,
stacklevel=2,
)
if self.label() is not None and angle != self.label().angle():
self.label().setAngle(angle)
return None
class TargetLabel(TextItem):
"""A TextItem that attaches itself to a TargetItem.
This class extends TextItem with the following features :
* Automatically positions adjacent to the symbol at a fixed position.
* Automatically reformats text when the symbol location has changed.
Parameters
----------
target : TargetItem
The TargetItem to which this label will be attached to.
text : str or callable, Optional
Governs the text displayed, can be a fixed string or a format string
that accepts the x, and y position of the target item; or be a callable
method that accepts a tuple (x, y) and returns a string to be displayed.
If None, an empty string is used. Default is None
offset : tuple or list or QPointF or QPoint
Position to set the anchor of the TargetLabel away from the center of
the target in pixels, by default it is (20, 0).
anchor : tuple, list, QPointF or QPoint
Position to rotate the TargetLabel about, and position to set the
offset value to see :class:`~pyqtgraph.TextItem` for more inforation.
kwargs : dict of arguments that are passed on to
:class:`~pyqtgraph.TextItem` constructor, excluding text parameter
"""
def __init__(
self,
target,
text="",
offset=(20, 0),
anchor=(0, 0.5),
**kwargs,
):
super().__init__(anchor=anchor, **kwargs)
self.setParentItem(target)
self.target = target
self.setFormat(text)
if isinstance(offset, Point):
self.offset = offset
elif isinstance(offset, (tuple, list)):
self.offset = Point(*offset)
elif isinstance(offset, (QtCore.QPoint, QtCore.QPointF)):
self.offset = Point(offset.x(), offset.y())
else:
raise TypeError("Offset parameter is the wrong data type")
self.target.sigPositionChanged.connect(self.valueChanged)
self.valueChanged()
def format(self):
return self._format
def setFormat(self, text):
"""Method to set how the TargetLabel should display the text. This
method should be called from TargetItem.setLabel directly.
Parameters
----------
text : Callable or str
Details how to format the text.
If Callable, then the label will display the result of ``text(x, y)``
If a fromatted string, then the output of ``text.format(x, y)`` will be
displayed
If a non-formatted string, then the text label will display ``text``
"""
if not callable(text):
parsed = list(string.Formatter().parse(text))
if parsed and parsed[0][1] is not None:
self.setProperty("formattableText", True)
else:
self.setText(text)
self.setProperty("formattableText", False)
else:
self.setProperty("formattableText", False)
self._format = text
self.valueChanged()
def valueChanged(self):
x, y = self.target.pos()
if self.property("formattableText"):
self.setText(self._format.format(float(x), float(y)))
elif callable(self._format):
self.setText(self._format(x, y))
def viewTransformChanged(self):
viewbox = self.getViewBox()
if isinstance(viewbox, ViewBox):
viewPixelSize = viewbox.viewPixelSize()
scaledOffset = QtCore.QPointF(
self.offset.x() * viewPixelSize[0], self.offset.y() * viewPixelSize[1]
)
self.setPos(scaledOffset)
return super().viewTransformChanged()
def mouseClickEvent(self, ev):
return self.parentItem().mouseClickEvent(ev)
def mouseDragEvent(self, ev):
targetItem = self.parentItem()
if not targetItem.movable or int(ev.button() & QtCore.Qt.LeftButton) == 0:
return
ev.accept()
if ev.isStart():
targetItem.symbolOffset = targetItem.pos() - self.mapToView(
ev.buttonDownPos()
)
targetItem.moving = True
if not targetItem.moving:
return
targetItem.setPos(targetItem.symbolOffset + self.mapToView(ev.pos()))
if ev.isFinish():
targetItem.moving = False
targetItem.sigPositionChangeFinished.emit(self)