From 653c91a683da40e7d0d72b70c3c6a41fcb68a903 Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Fri, 15 Sep 2017 09:14:26 -0700 Subject: [PATCH 1/2] InfiniteLine: add markers and ability to limit drawing region --- pyqtgraph/graphicsItems/InfiniteLine.py | 200 +++++++++++++++++++++--- 1 file changed, 174 insertions(+), 26 deletions(-) diff --git a/pyqtgraph/graphicsItems/InfiniteLine.py b/pyqtgraph/graphicsItems/InfiniteLine.py index 3da82327..7aeb1620 100644 --- a/pyqtgraph/graphicsItems/InfiniteLine.py +++ b/pyqtgraph/graphicsItems/InfiniteLine.py @@ -31,7 +31,8 @@ class InfiniteLine(GraphicsObject): sigPositionChanged = QtCore.Signal(object) def __init__(self, pos=None, angle=90, pen=None, movable=False, bounds=None, - hoverPen=None, label=None, labelOpts=None, name=None): + hoverPen=None, label=None, labelOpts=None, span=(0, 1), markers=None, + name=None): """ =============== ================================================================== **Arguments:** @@ -41,22 +42,28 @@ class InfiniteLine(GraphicsObject): pen Pen to use when drawing line. Can be any arguments that are valid for :func:`mkPen `. Default pen is transparent yellow. + hoverPen Pen to use when the mouse cursor hovers over the line. + Only used when movable=True. movable If True, the line can be dragged to a new position by the user. + bounds Optional [min, max] bounding values. Bounds are only valid if the + line is vertical or horizontal. hoverPen Pen to use when drawing line when hovering over it. Can be any arguments that are valid for :func:`mkPen `. Default pen is red. - bounds Optional [min, max] bounding values. Bounds are only valid if the - line is vertical or horizontal. label Text to be displayed in a label attached to the line, or None to show no label (default is None). May optionally include formatting strings to display the line value. labelOpts A dict of keyword arguments to use when constructing the text label. See :class:`InfLineLabel`. + span Optional tuple (min, max) giving the range over the view to draw + the line. For example, with a vertical line, use span=(0.5, 1) + to draw only on the top half of the view. + markers List of (marker, position, size) tuples, one per marker to display + on the line. See the addMarker method. name Name of the item =============== ================================================================== """ self._boundingRect = None - self._line = None self._name = name @@ -79,11 +86,25 @@ class InfiniteLine(GraphicsObject): if pen is None: pen = (200, 200, 100) self.setPen(pen) + if hoverPen is None: self.setHoverPen(color=(255,0,0), width=self.pen.width()) else: self.setHoverPen(hoverPen) + + self.span = span self.currentPen = self.pen + + self.markers = [] + self._maxMarkerSize = 0 + if markers is not None: + for m in markers: + self.addMarker(*m) + + # Cache variables for managing bounds + self._endPoints = [0, 1] # + self._bounds = None + self._lastViewSize = None if label is not None: labelOpts = {} if labelOpts is None else labelOpts @@ -98,7 +119,12 @@ class InfiniteLine(GraphicsObject): """Set the (minimum, maximum) allowable values when dragging.""" self.maxRange = bounds self.setValue(self.value()) - + + def bounds(self): + """Return the (minimum, maximum) values allowed when dragging. + """ + return self.maxRange[:] + def setPen(self, *args, **kwargs): """Set the pen for drawing the line. Allowable arguments are any that are valid for :func:`mkPen `.""" @@ -115,11 +141,70 @@ class InfiniteLine(GraphicsObject): If the line is not movable, then hovering is also disabled. Added in version 0.9.9.""" + # If user did not supply a width, then copy it from pen + widthSpecified = ((len(args) == 1 and + (isinstance(args[0], QtGui.QPen) or + (isinstance(args[0], dict) and 'width' in args[0])) + ) or 'width' in kwargs) self.hoverPen = fn.mkPen(*args, **kwargs) + if not widthSpecified: + self.hoverPen.setWidth(self.pen.width()) + if self.mouseHovering: self.currentPen = self.hoverPen self.update() + + def addMarker(self, marker, position=0.5, size=10.0): + """Add a marker to be displayed on the line. + + ============= ========================================================= + **Arguments** + marker String indicating the style of marker to add: + '<|', '|>', '>|', '|<', '<|>', '>|<', '^', 'v', 'o' + position Position (0.0-1.0) along the visible extent of the line + to place the marker. Default is 0.5. + size Size of the marker in pixels. Default is 10.0. + ============= ========================================================= + """ + path = QtGui.QPainterPath() + if marker == 'o': + path.addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1)) + if '<|' in marker: + p = QtGui.QPolygonF([Point(0.5, 0), Point(0, -0.5), Point(-0.5, 0)]) + path.addPolygon(p) + path.closeSubpath() + if '|>' in marker: + p = QtGui.QPolygonF([Point(0.5, 0), Point(0, 0.5), Point(-0.5, 0)]) + path.addPolygon(p) + path.closeSubpath() + if '>|' in marker: + p = QtGui.QPolygonF([Point(0.5, -0.5), Point(0, 0), Point(-0.5, -0.5)]) + path.addPolygon(p) + path.closeSubpath() + if '|<' in marker: + p = QtGui.QPolygonF([Point(0.5, 0.5), Point(0, 0), Point(-0.5, 0.5)]) + path.addPolygon(p) + path.closeSubpath() + if '^' in marker: + p = QtGui.QPolygonF([Point(0, -0.5), Point(0.5, 0), Point(0, 0.5)]) + path.addPolygon(p) + path.closeSubpath() + if 'v' in marker: + p = QtGui.QPolygonF([Point(0, -0.5), Point(-0.5, 0), Point(0, 0.5)]) + path.addPolygon(p) + path.closeSubpath() + + self.markers.append((path, position, size)) + self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) + self.update() + def clearMarkers(self): + """ Remove all markers from this line. + """ + self.markers = [] + self._maxMarkerSize = 0 + self.update() + def setAngle(self, angle): """ Takes angle argument in degrees. @@ -128,7 +213,7 @@ class InfiniteLine(GraphicsObject): Note that the use of value() and setValue() changes if the line is not vertical or horizontal. """ - self.angle = ((angle+45) % 180) - 45 ## -45 <= angle < 135 + self.angle = angle #((angle+45) % 180) - 45 ## -45 <= angle < 135 self.resetTransform() self.rotate(self.angle) self.update() @@ -199,35 +284,98 @@ class InfiniteLine(GraphicsObject): #else: #print "ignore", change #return GraphicsObject.itemChange(self, change, val) + + def setSpan(self, mn, mx): + if self.span != (mn, mx): + self.span = (mn, mx) + self.update() def _invalidateCache(self): - self._line = None self._boundingRect = None + def _computeBoundingRect(self): + #br = UIGraphicsItem.boundingRect(self) + vr = self.viewRect() # bounds of containing ViewBox mapped to local coords. + if vr is None: + return QtCore.QRectF() + + ## add a 4-pixel radius around the line for mouse interaction. + + px = self.pixelLength(direction=Point(1,0), ortho=True) ## get pixel length orthogonal to the line + if px is None: + px = 0 + pw = max(self.pen.width() / 2, self.hoverPen.width() / 2) + w = max(4, self._maxMarkerSize + pw) + 1 + w = w * px + br = QtCore.QRectF(vr) + br.setBottom(-w) + br.setTop(w) + + length = br.width() + left = br.left() + length * self.span[0] + right = br.left() + length * self.span[1] + br.setLeft(left - w) + br.setRight(right + w) + br = br.normalized() + + vs = self.getViewBox().size() + + if self._bounds != br or self._lastViewSize != vs: + self._bounds = br + self._lastViewSize = vs + self.prepareGeometryChange() + + self._endPoints = (left, right) + self._lastViewRect = vr + + return self._bounds + def boundingRect(self): if self._boundingRect is None: - #br = UIGraphicsItem.boundingRect(self) - br = self.viewRect() - if br is None: - return QtCore.QRectF() - - ## add a 4-pixel radius around the line for mouse interaction. - px = self.pixelLength(direction=Point(1,0), ortho=True) ## get pixel length orthogonal to the line - if px is None: - px = 0 - w = (max(4, self.pen.width()/2, self.hoverPen.width()/2)+1) * px - br.setBottom(-w) - br.setTop(w) - - br = br.normalized() - self._boundingRect = br - self._line = QtCore.QLineF(br.right(), 0.0, br.left(), 0.0) + self._boundingRect = self._computeBoundingRect() return self._boundingRect def paint(self, p, *args): - p.setPen(self.currentPen) - p.drawLine(self._line) - + p.setRenderHint(p.Antialiasing) + + left, right = self._endPoints + pen = self.currentPen + pen.setJoinStyle(QtCore.Qt.MiterJoin) + p.setPen(pen) + p.drawLine(Point(left, 0), Point(right, 0)) + + + if len(self.markers) == 0: + return + + # paint markers in native coordinate system + tr = p.transform() + p.resetTransform() + + start = tr.map(Point(left, 0)) + end = tr.map(Point(right, 0)) + up = tr.map(Point(left, 1)) + dif = end - start + length = Point(dif).length() + angle = np.arctan2(dif.y(), dif.x()) * 180 / np.pi + + p.translate(start) + p.rotate(angle) + + up = up - start + det = up.x() * dif.y() - dif.x() * up.y() + p.scale(1, 1 if det > 0 else -1) + + p.setBrush(fn.mkBrush(self.currentPen.color())) + #p.setPen(fn.mkPen(None)) + tr = p.transform() + for path, pos, size in self.markers: + p.setTransform(tr) + x = length * pos + p.translate(x, 0) + p.scale(size, size) + p.drawPath(path) + def dataBounds(self, axis, frac=1.0, orthoRange=None): if axis == 0: return None ## x axis should never be auto-scaled From 98cdc65049a8b61169982bb7add833f923979bf4 Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Thu, 21 Sep 2017 09:04:06 -0700 Subject: [PATCH 2/2] Update LinearRegionItem to support new InfiniteLine features Also add methods for setting hover brush and configurable line swap behavior (block/push/sort) --- pyqtgraph/graphicsItems/LinearRegionItem.py | 222 ++++++++++---------- 1 file changed, 114 insertions(+), 108 deletions(-) diff --git a/pyqtgraph/graphicsItems/LinearRegionItem.py b/pyqtgraph/graphicsItems/LinearRegionItem.py index e139190b..9903dac5 100644 --- a/pyqtgraph/graphicsItems/LinearRegionItem.py +++ b/pyqtgraph/graphicsItems/LinearRegionItem.py @@ -1,14 +1,14 @@ from ..Qt import QtGui, QtCore -from .UIGraphicsItem import UIGraphicsItem +from .GraphicsObject import GraphicsObject from .InfiniteLine import InfiniteLine from .. import functions as fn from .. import debug as debug __all__ = ['LinearRegionItem'] -class LinearRegionItem(UIGraphicsItem): +class LinearRegionItem(GraphicsObject): """ - **Bases:** :class:`UIGraphicsItem ` + **Bases:** :class:`GraphicsObject ` Used for marking a horizontal or vertical region in plots. The region can be dragged and is bounded by lines which can be dragged individually. @@ -26,65 +26,110 @@ class LinearRegionItem(UIGraphicsItem): sigRegionChanged = QtCore.Signal(object) Vertical = 0 Horizontal = 1 + _orientation_axis = { + Vertical: 0, + Horizontal: 1, + 'vertical': 0, + 'horizontal': 1, + } - def __init__(self, values=[0,1], orientation=None, brush=None, movable=True, bounds=None): + def __init__(self, values=(0, 1), orientation='vertical', brush=None, pen=None, + hoverBrush=None, hoverPen=None, movable=True, bounds=None, + span=(0, 1), swapMode='sort'): """Create a new LinearRegionItem. ============== ===================================================================== **Arguments:** values A list of the positions of the lines in the region. These are not limits; limits can be set by specifying bounds. - orientation Options are LinearRegionItem.Vertical or LinearRegionItem.Horizontal. - If not specified it will be vertical. + orientation Options are 'vertical' or 'horizontal', indicating the + The default is 'vertical', indicating that the brush Defines the brush that fills the region. Can be any arguments that are valid for :func:`mkBrush `. Default is transparent blue. + pen The pen to use when drawing the lines that bound the region. + hoverBrush The brush to use when the mouse is hovering over the region. + hoverPen The pen to use when the mouse is hovering over the region. movable If True, the region and individual lines are movable by the user; if False, they are static. bounds Optional [min, max] bounding values for the region + span Optional [min, max] giving the range over the view to draw + the region. For example, with a vertical line, use span=(0.5, 1) + to draw only on the top half of the view. + swapMode Sets the behavior of the region when the lines are moved such that + their order reverses: + * "block" means the user cannot drag one line past the other + * "push" causes both lines to be moved if one would cross the other + * "sort" means that lines may trade places, but the output of + getRegion always gives the line positions in ascending order. + * None means that no attempt is made to handle swapped line + positions. + The default is "sort". ============== ===================================================================== """ - UIGraphicsItem.__init__(self) - if orientation is None: - orientation = LinearRegionItem.Vertical + GraphicsObject.__init__(self) self.orientation = orientation self.bounds = QtCore.QRectF() self.blockLineSignal = False self.moving = False self.mouseHovering = False + self.span = span + self.swapMode = swapMode + self._bounds = None - if orientation == LinearRegionItem.Horizontal: + # note LinearRegionItem.Horizontal and LinearRegionItem.Vertical + # are kept for backward compatibility. + lineKwds = dict( + movable=movable, + bounds=bounds, + span=span, + pen=pen, + hoverPen=hoverPen, + ) + + if orientation in ('horizontal', LinearRegionItem.Horizontal): self.lines = [ - InfiniteLine(QtCore.QPointF(0, values[0]), 0, movable=movable, bounds=bounds), - InfiniteLine(QtCore.QPointF(0, values[1]), 0, movable=movable, bounds=bounds)] - elif orientation == LinearRegionItem.Vertical: + # rotate lines to 180 to preserve expected line orientation + # with respect to region. This ensures that placing a '<|' + # marker on lines[0] causes it to point left in vertical mode + # and down in horizontal mode. + InfiniteLine(QtCore.QPointF(0, values[0]), angle=0, **lineKwds), + InfiniteLine(QtCore.QPointF(0, values[1]), angle=0, **lineKwds)] + self.lines[0].scale(1, -1) + self.lines[1].scale(1, -1) + elif orientation in ('vertical', LinearRegionItem.Vertical): self.lines = [ - InfiniteLine(QtCore.QPointF(values[1], 0), 90, movable=movable, bounds=bounds), - InfiniteLine(QtCore.QPointF(values[0], 0), 90, movable=movable, bounds=bounds)] + InfiniteLine(QtCore.QPointF(values[0], 0), angle=90, **lineKwds), + InfiniteLine(QtCore.QPointF(values[1], 0), angle=90, **lineKwds)] else: - raise Exception('Orientation must be one of LinearRegionItem.Vertical or LinearRegionItem.Horizontal') - + raise Exception("Orientation must be 'vertical' or 'horizontal'.") for l in self.lines: l.setParentItem(self) l.sigPositionChangeFinished.connect(self.lineMoveFinished) - l.sigPositionChanged.connect(self.lineMoved) + self.lines[0].sigPositionChanged.connect(lambda: self.lineMoved(0)) + self.lines[1].sigPositionChanged.connect(lambda: self.lineMoved(1)) if brush is None: brush = QtGui.QBrush(QtGui.QColor(0, 0, 255, 50)) self.setBrush(brush) + if hoverBrush is None: + c = self.brush.color() + c.setAlpha(min(c.alpha() * 2, 255)) + hoverBrush = fn.mkBrush(c) + self.setHoverBrush(hoverBrush) + self.setMovable(movable) def getRegion(self): """Return the values at the edges of the region.""" - #if self.orientation[0] == 'h': - #r = (self.bounds.top(), self.bounds.bottom()) - #else: - #r = (self.bounds.left(), self.bounds.right()) - r = [self.lines[0].value(), self.lines[1].value()] - return (min(r), max(r)) + r = (self.lines[0].value(), self.lines[1].value()) + if self.swapMode == 'sort': + return (min(r), max(r)) + else: + return r def setRegion(self, rgn): """Set the values for the edges of the region. @@ -101,7 +146,8 @@ class LinearRegionItem(UIGraphicsItem): self.blockLineSignal = False self.lines[1].setValue(rgn[1]) #self.blockLineSignal = False - self.lineMoved() + self.lineMoved(0) + self.lineMoved(1) self.lineMoveFinished() def setBrush(self, *br, **kargs): @@ -111,6 +157,13 @@ class LinearRegionItem(UIGraphicsItem): self.brush = fn.mkBrush(*br, **kargs) self.currentBrush = self.brush + def setHoverBrush(self, *br, **kargs): + """Set the brush that fills the region when the mouse is hovering over. + Can have any arguments that are valid + for :func:`mkBrush `. + """ + self.hoverBrush = fn.mkBrush(*br, **kargs) + def setBounds(self, bounds): """Optional [min, max] bounding values for the region. To have no bounds on the region use [None, None]. @@ -128,81 +181,67 @@ class LinearRegionItem(UIGraphicsItem): self.movable = m self.setAcceptHoverEvents(m) + def setSpan(self, mn, mx): + if self.span == (mn, mx): + return + self.span = (mn, mx) + self.lines[0].setSpan(mn, mx) + self.lines[1].setSpan(mn, mx) + self.update() + def boundingRect(self): - br = UIGraphicsItem.boundingRect(self) + br = self.viewRect() # bounds of containing ViewBox mapped to local coords. + rng = self.getRegion() - if self.orientation == LinearRegionItem.Vertical: + if self.orientation in ('vertical', LinearRegionItem.Vertical): br.setLeft(rng[0]) br.setRight(rng[1]) + length = br.height() + br.setBottom(br.top() + length * self.span[1]) + br.setTop(br.top() + length * self.span[0]) else: br.setTop(rng[0]) br.setBottom(rng[1]) - return br.normalized() + length = br.width() + br.setRight(br.left() + length * self.span[1]) + br.setLeft(br.left() + length * self.span[0]) + + br = br.normalized() + + if self._bounds != br: + self._bounds = br + self.prepareGeometryChange() + + return br def paint(self, p, *args): profiler = debug.Profiler() - UIGraphicsItem.paint(self, p, *args) p.setBrush(self.currentBrush) p.setPen(fn.mkPen(None)) p.drawRect(self.boundingRect()) def dataBounds(self, axis, frac=1.0, orthoRange=None): - if axis == self.orientation: + if axis == self._orientation_axis[self.orientation]: return self.getRegion() else: return None - def lineMoved(self): + def lineMoved(self, i): if self.blockLineSignal: return + + # lines swapped + if self.lines[0].value() > self.lines[1].value(): + if self.swapMode == 'block': + self.lines[i].setValue(self.lines[1-i].value()) + elif self.swapMode == 'push': + self.lines[1-i].setValue(self.lines[i].value()) + self.prepareGeometryChange() - #self.emit(QtCore.SIGNAL('regionChanged'), self) self.sigRegionChanged.emit(self) def lineMoveFinished(self): - #self.emit(QtCore.SIGNAL('regionChangeFinished'), self) self.sigRegionChangeFinished.emit(self) - - - #def updateBounds(self): - #vb = self.view().viewRect() - #vals = [self.lines[0].value(), self.lines[1].value()] - #if self.orientation[0] == 'h': - #vb.setTop(min(vals)) - #vb.setBottom(max(vals)) - #else: - #vb.setLeft(min(vals)) - #vb.setRight(max(vals)) - #if vb != self.bounds: - #self.bounds = vb - #self.rect.setRect(vb) - - #def mousePressEvent(self, ev): - #if not self.movable: - #ev.ignore() - #return - #for l in self.lines: - #l.mousePressEvent(ev) ## pass event to both lines so they move together - ##if self.movable and ev.button() == QtCore.Qt.LeftButton: - ##ev.accept() - ##self.pressDelta = self.mapToParent(ev.pos()) - QtCore.QPointF(*self.p) - ##else: - ##ev.ignore() - - #def mouseReleaseEvent(self, ev): - #for l in self.lines: - #l.mouseReleaseEvent(ev) - - #def mouseMoveEvent(self, ev): - ##print "move", ev.pos() - #if not self.movable: - #return - #self.lines[0].blockSignals(True) # only want to update once - #for l in self.lines: - #l.mouseMoveEvent(ev) - #self.lines[0].blockSignals(False) - ##self.setPos(self.mapToParent(ev.pos()) - self.pressDelta) - ##self.emit(QtCore.SIGNAL('dragged'), self) def mouseDragEvent(self, ev): if not self.movable or int(ev.button() & QtCore.Qt.LeftButton) == 0: @@ -218,12 +257,9 @@ class LinearRegionItem(UIGraphicsItem): if not self.moving: return - #delta = ev.pos() - ev.lastPos() self.lines[0].blockSignals(True) # only want to update once for i, l in enumerate(self.lines): l.setPos(self.cursorOffsets[i] + ev.pos()) - #l.setPos(l.pos()+delta) - #l.mouseDragEvent(ev) self.lines[0].blockSignals(False) self.prepareGeometryChange() @@ -242,7 +278,6 @@ class LinearRegionItem(UIGraphicsItem): self.sigRegionChanged.emit(self) self.sigRegionChangeFinished.emit(self) - def hoverEvent(self, ev): if self.movable and (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton): self.setMouseHover(True) @@ -255,36 +290,7 @@ class LinearRegionItem(UIGraphicsItem): return self.mouseHovering = hover if hover: - c = self.brush.color() - c.setAlpha(c.alpha() * 2) - self.currentBrush = fn.mkBrush(c) + self.currentBrush = self.hoverBrush else: self.currentBrush = self.brush self.update() - - #def hoverEnterEvent(self, ev): - #print "rgn hover enter" - #ev.ignore() - #self.updateHoverBrush() - - #def hoverMoveEvent(self, ev): - #print "rgn hover move" - #ev.ignore() - #self.updateHoverBrush() - - #def hoverLeaveEvent(self, ev): - #print "rgn hover leave" - #ev.ignore() - #self.updateHoverBrush(False) - - #def updateHoverBrush(self, hover=None): - #if hover is None: - #scene = self.scene() - #hover = scene.claimEvent(self, QtCore.Qt.LeftButton, scene.Drag) - - #if hover: - #self.currentBrush = fn.mkBrush(255, 0,0,100) - #else: - #self.currentBrush = self.brush - #self.update() -