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:
parent
ddab4180e9
commit
ba7129a719
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 = [
|
||||
|
@ -128,6 +138,7 @@ class LinearRegionItem(GraphicsObject):
|
|||
|
||||
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)
|
||||
"""Set ``(min, max)`` bounding values for the region.
|
||||
|
||||
def setMovable(self, m):
|
||||
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
|
||||
|
|
|
@ -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)
|
Loading…
Reference in New Issue