Add option to limit LinearRegionitem bounds to a secondary item (#1834)

* Added clipItem option to LinearRegionItem

* Added a clipItem option to LinearRegionItem

Handle case when no self.viewBox() is yet available

* Implement LinearRegionItem clipItem

* Undo unnecessary change

* Update clipItem doc

* Fixup docstring formatting

* Cleanup

* Support clearing clipItem via setBounds. Fix initialization bug

* Add tests for LinearRegionItem clipItem

* Better clipItem demo in crosshair example

* Another test to verify claim in docstring

Co-authored-by: Arjun Chennu <arjun.chennu@gmail.com>
Co-authored-by: Ogi Moore <ognyan.moore@gmail.com>
Co-authored-by: Arjun Chennu <achennu@mpi-bremen.de>
This commit is contained in:
Kenneth Lyons 2021-07-17 21:02:06 -07:00 committed by GitHub
parent ddab4180e9
commit ba7129a719
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 200 additions and 29 deletions

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
"""
Demonstrates some customized mouse interaction by drawing a crosshair that follows
the mouse.
@ -38,7 +39,9 @@ data2 = 15000 + 15000 * pg.gaussianFilter(np.random.random(size=10000), 10) + 30
p1.plot(data1, pen="r")
p1.plot(data2, pen="g")
p2.plot(data1, pen="w")
p2d = p2.plot(data1, pen="w")
# bound the LinearRegionItem to the plotted data
region.setClipItem(p2d)
def update():
region.setZValue(10)

View File

@ -106,7 +106,7 @@ def imageHoverEvent(event):
val = data[i, j]
ppos = img.mapToParent(pos)
x, y = ppos.x(), ppos.y()
p1.setTitle("pos: (%0.1f, %0.1f) pixel: (%d, %d) value: %g" % (x, y, i, j, val))
p1.setTitle("pos: (%0.1f, %0.1f) pixel: (%d, %d) value: %.3g" % (x, y, i, j, val))
# Monkey-patch the image to use our custom hover function.
# This is generally discouraged (you should subclass ImageItem instead),

View File

@ -36,7 +36,7 @@ class LinearRegionItem(GraphicsObject):
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'):
span=(0, 1), swapMode='sort', clipItem=None):
"""Create a new LinearRegionItem.
============== =====================================================================
@ -60,25 +60,35 @@ class LinearRegionItem(GraphicsObject):
``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".
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".
clipItem An item whose bounds will be used to limit the region bounds.
This is useful when a LinearRegionItem is added on top of an
:class:`~pyqtgraph.ImageItem` or
:class:`~pyqtgraph.PlotDataItem` and the visual region should
not extend beyond its range. This overrides ``bounds``.
============== =====================================================================
"""
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
self.clipItem = clipItem
self._boundingRectCache = None
self._clipItemBoundsCache = None
# note LinearRegionItem.Horizontal and LinearRegionItem.Vertical
# are kept for backward compatibility.
@ -88,7 +98,7 @@ class LinearRegionItem(GraphicsObject):
span=span,
pen=pen,
hoverPen=hoverPen,
)
)
if orientation in ('horizontal', LinearRegionItem.Horizontal):
self.lines = [
@ -125,9 +135,10 @@ class LinearRegionItem(GraphicsObject):
self.setHoverBrush(hoverBrush)
self.setMovable(movable)
def getRegion(self):
"""Return the values at the edges of the region."""
r = (self.lines[0].value(), self.lines[1].value())
if self.swapMode == 'sort':
return (min(r), max(r))
@ -168,19 +179,28 @@ class LinearRegionItem(GraphicsObject):
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].
Does not affect the current position of the region unless it is outside the new bounds.
See :func:`setRegion <pyqtgraph.LinearRegionItem.setRegion>` to set the position
of the region."""
for l in self.lines:
l.setBounds(bounds)
def setMovable(self, m):
"""Set ``(min, max)`` bounding values for the region.
The current position is only affected it is outside the new bounds. See
:func:`~pyqtgraph.LinearRegionItem.setRegion` to set the position of the region.
Use ``(None, None)`` to disable bounds.
"""
if self.clipItem is not None:
self.setClipItem(None)
self._setBounds(bounds)
def _setBounds(self, bounds):
# internal impl so user-facing setBounds can clear clipItem and clipItem can
# set bounds without clearing itself
for line in self.lines:
line.setBounds(bounds)
def setMovable(self, m=True):
"""Set lines to be movable by the user, or not. If lines are movable, they will
also accept HoverEvents."""
for l in self.lines:
l.setMovable(m)
for line in self.lines:
line.setMovable(m)
self.movable = m
self.setAcceptHoverEvents(m)
@ -188,13 +208,45 @@ class LinearRegionItem(GraphicsObject):
if self.span == (mn, mx):
return
self.span = (mn, mx)
self.lines[0].setSpan(mn, mx)
self.lines[1].setSpan(mn, mx)
for line in self.lines:
line.setSpan(mn, mx)
self.update()
def setClipItem(self, item=None):
"""Set an item to which the region is bounded.
If ``None``, bounds are disabled.
"""
self.clipItem = item
self._clipItemBoundsCache = None
if item is None:
self._setBounds((None, None))
if item is not None:
self._updateClipItemBounds()
def _updateClipItemBounds(self):
# set region bounds corresponding to clipItem
item_vb = self.clipItem.getViewBox()
if item_vb is None:
return
item_bounds = item_vb.childrenBounds(items=(self.clipItem,))
if item_bounds == self._clipItemBoundsCache or None in item_bounds:
return
self._clipItemBoundsCache = item_bounds
if self.orientation in ('horizontal', LinearRegionItem.Horizontal):
self._setBounds(item_bounds[1])
else:
self._setBounds(item_bounds[0])
def boundingRect(self):
br = QtCore.QRectF(self.viewRect()) # bounds of containing ViewBox mapped to local coords.
if self.clipItem is not None:
self._updateClipItemBounds()
rng = self.getRegion()
if self.orientation in ('vertical', LinearRegionItem.Vertical):
br.setLeft(rng[0])
@ -211,8 +263,8 @@ class LinearRegionItem(GraphicsObject):
br = br.normalized()
if self._bounds != br:
self._bounds = br
if self._boundingRectCache != br:
self._boundingRectCache = br
self.prepareGeometryChange()
return br

View File

@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
import pytest
import pyqtgraph as pg
import numpy as np
import math
app = pg.mkQApp()
def check_region(lr, bounds, exact=False, rtol=0.005):
"""Optionally tolerant LinearRegionItem region check"""
reg = lr.getRegion()
if exact:
assert reg[0] == bounds[0]
assert reg[1] == bounds[1]
else:
assert math.isclose(reg[0], bounds[0], rel_tol=rtol)
assert math.isclose(reg[1], bounds[1], rel_tol=rtol)
@pytest.mark.parametrize("orientation", ["vertical", "horizontal"])
def test_clip_to_plot_data_item(orientation):
"""Vertical and horizontal LRIs clipping both bounds to a PlotDataItem"""
# initial bounds for the LRI
init_vals = (-1.5, 1.5)
# data for a PlotDataItem to clip to, both inside the inial bounds
x = np.linspace(-1, 1, 10)
y = np.linspace(1, 1.2, 10)
p = pg.PlotWidget()
pdi = p.plot(x=x, y=y)
lr = pg.LinearRegionItem(init_vals, clipItem=pdi, orientation=orientation)
p.addItem(lr)
app.processEvents()
if orientation == "vertical":
check_region(lr, x[[0, -1]])
else:
check_region(lr, y[[0, -1]])
def test_disable_clip_item():
"""LRI clipItem (ImageItem) disabled by explicit call to setBounds"""
init_vals = (5, 40)
p = pg.PlotWidget()
img = pg.ImageItem(image=np.eye(20, 20))
p.addItem(img)
lr = pg.LinearRegionItem(init_vals, clipItem=img)
p.addItem(lr)
app.processEvents()
# initial bound that was out of range snaps to the imageitem bound
check_region(lr, (init_vals[0], img.height()), exact=True)
# disable clipItem, move the right InfiniteLine beyond the new bound
lr.setBounds(init_vals)
lr.lines[1].setPos(init_vals[1] + 10)
app.processEvents()
check_region(lr, init_vals, exact=True)
def test_clip_to_item_in_other_vb():
"""LRI clip to item in a different ViewBox"""
init_vals = (10, 50)
img_shape = (20, 20)
win = pg.GraphicsLayoutWidget()
# "remote" PlotDataItem to bound to
p1 = win.addPlot()
img = pg.ImageItem(image=np.eye(*img_shape))
p1.addItem(img)
# plot the LRI lives in
p2 = win.addPlot()
x2 = np.linspace(-200, 200, 100)
p2.plot(x=x2, y=x2)
lr = pg.LinearRegionItem(init_vals)
p2.addItem(lr)
app.processEvents()
# no clip item set yet, should be initial bounds
check_region(lr, init_vals, exact=True)
lr.setClipItem(img)
app.processEvents()
# snap to image width
check_region(lr, (init_vals[0], img_shape[1]), exact=True)
def test_clip_item_override_init_bounds():
"""clipItem overrides bounds provided in the constructor"""
init_vals = (-10, 10)
init_bounds = (-5, 5)
img_shape = (5, 5)
p = pg.PlotWidget()
img = pg.ImageItem(image=np.eye(*img_shape))
p.addItem(img)
lr = pg.LinearRegionItem(init_vals, clipItem=img, bounds=init_bounds)
p.addItem(lr)
app.processEvents()
check_region(lr, (0, img_shape[1]), exact=True)