From a4103dd1526ad4d159ae3a9c74cff7e859efafb0 Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Sun, 3 Nov 2013 17:10:59 -0500 Subject: [PATCH] Mid-way through overhaul. Proposed code path looks like: setRange -> updateViewRange -> matrix dirty -> sigRangeChanged ... -> prepareForPaint -> updateAutoRange, updateMatrix if dirty --- pyqtgraph/graphicsItems/GraphicsObject.py | 3 +- pyqtgraph/graphicsItems/ViewBox/ViewBox.py | 314 +++++++++++++++------ pyqtgraph/graphicsItems/tests/ViewBox.py | 83 ++++-- 3 files changed, 280 insertions(+), 120 deletions(-) diff --git a/pyqtgraph/graphicsItems/GraphicsObject.py b/pyqtgraph/graphicsItems/GraphicsObject.py index e4c5cd81..d8f55d27 100644 --- a/pyqtgraph/graphicsItems/GraphicsObject.py +++ b/pyqtgraph/graphicsItems/GraphicsObject.py @@ -12,6 +12,7 @@ class GraphicsObject(GraphicsItem, QtGui.QGraphicsObject): """ _qtBaseClass = QtGui.QGraphicsObject def __init__(self, *args): + self.__inform_view_on_changes = True QtGui.QGraphicsObject.__init__(self, *args) self.setFlag(self.ItemSendsGeometryChanges) GraphicsItem.__init__(self) @@ -20,7 +21,7 @@ class GraphicsObject(GraphicsItem, QtGui.QGraphicsObject): ret = QtGui.QGraphicsObject.itemChange(self, change, value) if change in [self.ItemParentHasChanged, self.ItemSceneHasChanged]: self.parentChanged() - if change in [self.ItemPositionHasChanged, self.ItemTransformHasChanged]: + if self.__inform_view_on_changes and change in [self.ItemPositionHasChanged, self.ItemTransformHasChanged]: self.informViewBoundsChanged() ## workaround for pyqt bug: diff --git a/pyqtgraph/graphicsItems/ViewBox/ViewBox.py b/pyqtgraph/graphicsItems/ViewBox/ViewBox.py index e57ea1ee..5241489c 100644 --- a/pyqtgraph/graphicsItems/ViewBox/ViewBox.py +++ b/pyqtgraph/graphicsItems/ViewBox/ViewBox.py @@ -17,6 +17,10 @@ __all__ = ['ViewBox'] class ChildGroup(ItemGroup): sigItemsChanged = QtCore.Signal() + def __init__(self, parent): + ItemGroup.__init__(self, parent) + # excempt from telling view when transform changes + self._GraphicsObject__inform_view_on_change = False def itemChange(self, change, value): ret = ItemGroup.itemChange(self, change, value) @@ -195,6 +199,21 @@ class ViewBox(GraphicsWidget): def implements(self, interface): return interface == 'ViewBox' + def itemChange(self, change, value): + ret = GraphicsWidget.itemChange(self, change, value) + if change == self.ItemSceneChange: + scene = self.scene() + if scene is not None: + scene.sigPrepareForPaint.disconnect(self.prepareForPaint) + elif change == self.ItemSceneHasChanged: + scene = self.scene() + if scene is not None: + scene.sigPrepareForPaint.connect(self.prepareForPaint) + return ret + + def prepareForPaint(self): + #print "prepare" + pass def getState(self, copy=True): """Return the current state of the ViewBox. @@ -308,12 +327,14 @@ class ViewBox(GraphicsWidget): ch.setParentItem(None) def resizeEvent(self, ev): - #print self.name, "ViewBox.resizeEvent", self.size() + print self.name, "ViewBox.resizeEvent", self.size() #self.setRange(self.range, padding=0) + x,y = self.targetRange() + self.setRange(xRange=x, yRange=y, padding=0) self.linkedXChanged() self.linkedYChanged() self.updateAutoRange() - self.updateMatrix() + #self.updateMatrix() self.sigStateChanged.emit(self) self.background.setRect(self.rect()) #self._itemBoundsCache.clear() @@ -357,77 +378,97 @@ class ViewBox(GraphicsWidget): Set the visible range of the ViewBox. Must specify at least one of *rect*, *xRange*, or *yRange*. - ============= ===================================================================== + ================== ===================================================================== **Arguments** - *rect* (QRectF) The full range that should be visible in the view box. - *xRange* (min,max) The range that should be visible along the x-axis. - *yRange* (min,max) The range that should be visible along the y-axis. - *padding* (float) Expand the view by a fraction of the requested range. - By default, this value is set between 0.02 and 0.1 depending on - the size of the ViewBox. - ============= ===================================================================== + *rect* (QRectF) The full range that should be visible in the view box. + *xRange* (min,max) The range that should be visible along the x-axis. + *yRange* (min,max) The range that should be visible along the y-axis. + *padding* (float) Expand the view by a fraction of the requested range. + By default, this value is set between 0.02 and 0.1 depending on + the size of the ViewBox. + *update* (bool) If True, update the range of the ViewBox immediately. + Otherwise, the update is deferred until before the next render. + *disableAutoRange* (bool) If True, auto-ranging is diabled. Otherwise, it is left + unchanged. + ================== ===================================================================== """ - #print self.name, "ViewBox.setRange", rect, xRange, yRange, padding + print self.name, "ViewBox.setRange", rect, xRange, yRange, padding - changes = {} + changes = {} # axes + setRequested = [False, False] if rect is not None: changes = {0: [rect.left(), rect.right()], 1: [rect.top(), rect.bottom()]} + setRequested = [True, True] if xRange is not None: changes[0] = xRange + setRequested[0] = True if yRange is not None: changes[1] = yRange + setRequested[1] = True if len(changes) == 0: print(rect) raise Exception("Must specify at least one of rect, xRange, or yRange. (gave rect=%s)" % str(type(rect))) + # Update axes one at a time changed = [False, False] for ax, range in changes.items(): - if padding is None: - xpad = self.suggestPadding(ax) - else: - xpad = padding mn = min(range) mx = max(range) - if mn == mx: ## If we requested 0 range, try to preserve previous scale. Otherwise just pick an arbitrary scale. + + # If we requested 0 range, try to preserve previous scale. + # Otherwise just pick an arbitrary scale. + if mn == mx: dy = self.state['viewRange'][ax][1] - self.state['viewRange'][ax][0] if dy == 0: dy = 1 mn -= dy*0.5 mx += dy*0.5 xpad = 0.0 - if any(np.isnan([mn, mx])) or any(np.isinf([mn, mx])): - raise Exception("Not setting range [%s, %s]" % (str(mn), str(mx))) + # Make sure no nan/inf get through + if not all(np.isfinite([mn, mx])): + raise Exception("Cannot set range [%s, %s]" % (str(mn), str(mx))) + + # Apply padding + if padding is None: + xpad = self.suggestPadding(ax) + else: + xpad = padding p = (mx-mn) * xpad mn -= p mx += p + + # Set target range if self.state['targetRange'][ax] != [mn, mx]: self.state['targetRange'][ax] = [mn, mx] changed[ax] = True - aspect = self.state['aspectLocked'] # size ratio / view ratio - if aspect is not False and len(changes) == 1: - ## need to adjust orthogonal target range to match - size = [self.width(), self.height()] - tr1 = self.state['targetRange'][ax] - tr2 = self.state['targetRange'][1-ax] - if size[1] == 0 or aspect == 0: - ratio = 1.0 - else: - ratio = (size[0] / float(size[1])) / aspect - if ax == 0: - ratio = 1.0 / ratio - w = (tr1[1]-tr1[0]) * ratio - d = 0.5 * (w - (tr2[1]-tr2[0])) - self.state['targetRange'][1-ax] = [tr2[0]-d, tr2[1]+d] - - - + # Update viewRange to match targetRange as closely as possible while + # accounting for aspect ratio constraint + lockX, lockY = setRequested + if lockX and lockY: + lockX = False + lockY = False + self.updateViewRange(lockX, lockY) - if any(changed) and disableAutoRange: + # If ortho axes have auto-visible-only, update them now + # Note that aspect ratio constraints and auto-visible probably do not work together.. + if changed[0] and self.state['autoVisibleOnly'][1]: + self.updateAutoRange() ## Maybe just indicate that auto range needs to be updated? + elif changed[1] and self.state['autoVisibleOnly'][0]: + self.updateAutoRange() + + # If nothing has changed, we are done. + if not any(changed): + #if update and self.matrixNeedsUpdate: + #self.updateMatrix(changed) + return + + # Disable auto-range if needed + if disableAutoRange: if all(changed): ax = ViewBox.XYAxes elif changed[0]: @@ -436,26 +477,26 @@ class ViewBox(GraphicsWidget): ax = ViewBox.YAxis self.enableAutoRange(ax, False) - self.sigStateChanged.emit(self) - self.target.setRect(self.mapRectFromItem(self.childGroup, self.targetRect())) + # Update target rect for debugging + if self.target.isVisible(): + self.target.setRect(self.mapRectFromItem(self.childGroup, self.targetRect())) - if update and (any(changed) or self.matrixNeedsUpdate): - self.updateMatrix(changed) - - if not update and any(changed): - self.matrixNeedsUpdate = True + ## Update view matrix only if requested + #if update: + #self.updateMatrix(changed) + ## Otherwise, indicate that the matrix needs to be updated + #else: + #self.matrixNeedsUpdate = True - for ax, range in changes.items(): - link = self.linkedView(ax) - if link is not None: - link.linkedViewChanged(self, ax) + ## Inform linked views that the range has changed <> + #for ax, range in changes.items(): + #link = self.linkedView(ax) + #if link is not None: + #link.linkedViewChanged(self, ax) + - if changed[0] and self.state['autoVisibleOnly'][1]: - self.updateAutoRange() - elif changed[1] and self.state['autoVisibleOnly'][0]: - self.updateAutoRange() def setYRange(self, min, max, padding=None, update=True): """ @@ -572,7 +613,7 @@ class ViewBox(GraphicsWidget): - def enableAutoRange(self, axis=None, enable=True): + def enableAutoRange(self, axis=None, enable=True, x=None, y=None): """ Enable (or disable) auto-range for *axis*, which may be ViewBox.XAxis, ViewBox.YAxis, or ViewBox.XYAxes for both (if *axis* is omitted, both axes will be changed). @@ -584,25 +625,40 @@ class ViewBox(GraphicsWidget): #if not enable: #import traceback #traceback.print_stack() - + + # support simpler interface: + if x is not None or y is not None: + if x is not None: + self.enableAutoRange(ViewBox.XAxis, x) + if y is not None: + self.enableAutoRange(ViewBox.YAxis, y) + return + if enable is True: enable = 1.0 if axis is None: axis = ViewBox.XYAxes + needAutoRangeUpdate = False + if axis == ViewBox.XYAxes or axis == 'xy': - self.state['autoRange'][0] = enable - self.state['autoRange'][1] = enable + axes = [0, 1] elif axis == ViewBox.XAxis or axis == 'x': - self.state['autoRange'][0] = enable + axes = [0] elif axis == ViewBox.YAxis or axis == 'y': - self.state['autoRange'][1] = enable + axes = [1] else: raise Exception('axis argument must be ViewBox.XAxis, ViewBox.YAxis, or ViewBox.XYAxes.') - if enable: + for ax in axes: + if self.state['autoRange'][ax] != enable: + self.state['autoRange'][ax] = enable + needAutoRangeUpdate |= (enable is not False) + + if needAutoRangeUpdate: self.updateAutoRange() + self.sigStateChanged.emit(self) def disableAutoRange(self, axis=None): @@ -728,6 +784,7 @@ class ViewBox(GraphicsWidget): if oldLink is not None: try: getattr(oldLink, signal).disconnect(slot) + oldLink.sigResized.disconnect(slot) except TypeError: ## This can occur if the view has been deleted already pass @@ -738,6 +795,7 @@ class ViewBox(GraphicsWidget): else: self.state['linkedViews'][axis] = weakref.ref(view) getattr(view, signal).connect(slot) + view.sigResized.connect(slot) if view.autoRangeEnabled()[axis] is not False: self.enableAutoRange(axis, False) slot() @@ -1233,47 +1291,126 @@ class ViewBox(GraphicsWidget): bounds = QtCore.QRectF(range[0][0], range[1][0], range[0][1]-range[0][0], range[1][1]-range[1][0]) return bounds + def updateViewRange(self, forceX=False, forceY=False): + ## Update viewRange to match targetRange as closely as possible, given + ## aspect ratio constraints. - - def updateMatrix(self, changed=None): - ## Make the childGroup's transform match the requested range. + viewRange = [self.state['targetRange'][0][:], self.state['targetRange'][1][:]] - if changed is None: - changed = [False, False] - changed = list(changed) + # Make correction for aspect ratio constraint + aspect = self.state['aspectLocked'] # size ratio / view ratio tr = self.targetRect() bounds = self.rect() - - ## set viewRect, given targetRect and possibly aspect ratio constraint - aspect = self.state['aspectLocked'] - if aspect is False or bounds.height() == 0: - self.state['viewRange'] = [self.state['targetRange'][0][:], self.state['targetRange'][1][:]] - else: + if aspect is not False and tr.width() != 0 and bounds.width() != 0: ## aspect is (widget w/h) / (view range w/h) + ## This is the view range aspect ratio we have requested targetRatio = tr.width() / tr.height() ## This is the view range aspect ratio we need to obey aspect constraint viewRatio = (bounds.width() / bounds.height()) / aspect - if targetRatio > viewRatio: + # Decide which range to keep unchanged + print self.name, "aspect:", aspect, "changed:", changed, "auto:", self.state['autoRange'] + if all(setRequested): + ax = 0 if targetRatio > viewRatio else 1 + print "ax:", ax + elif setRequested[0]: + ax = 0 + else: + ax = 1 + + #### these should affect viewRange, not targetRange! + if ax == 0: ## view range needs to be taller than target dy = 0.5 * (tr.width() / viewRatio - tr.height()) if dy != 0: changed[1] = True - self.state['viewRange'] = [ - self.state['targetRange'][0][:], - [self.state['targetRange'][1][0] - dy, self.state['targetRange'][1][1] + dy] - ] + self.state['targetRange'][1] = [self.state['targetRange'][1][0] - dy, self.state['targetRange'][1][1] + dy] + changed[1] = True else: ## view range needs to be wider than target dx = 0.5 * (tr.height() * viewRatio - tr.width()) if dx != 0: changed[0] = True - self.state['viewRange'] = [ - [self.state['targetRange'][0][0] - dx, self.state['targetRange'][0][1] + dx], - self.state['targetRange'][1][:] - ] + self.state['targetRange'][0] = [self.state['targetRange'][0][0] - dx, self.state['targetRange'][0][1] + dx] + changed[0] = True + + + ## need to adjust orthogonal target range to match + #size = [self.width(), self.height()] + #tr1 = self.state['targetRange'][ax] + #tr2 = self.state['targetRange'][1-ax] + #if size[1] == 0 or aspect == 0: + #ratio = 1.0 + #else: + #ratio = (size[0] / float(size[1])) / aspect + #if ax == 0: + #ratio = 1.0 / ratio + #w = (tr1[1]-tr1[0]) * ratio + #d = 0.5 * (w - (tr2[1]-tr2[0])) + #self.state['targetRange'][1-ax] = [tr2[0]-d, tr2[1]+d] + #changed[1-ax] = True + + self.state['viewRange'] = viewRange + + # emit range change signals here! + if changed[0]: + self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][0])) + if changed[1]: + self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][1])) + if any(changed): + self.sigRangeChanged.emit(self, self.state['viewRange']) + + # Inform linked views that the range has changed <> + for ax, range in changes.items(): + link = self.linkedView(ax) + if link is not None: + link.linkedViewChanged(self, ax) + + self._matrixNeedsUpdate = True + + def updateMatrix(self, changed=None): + ## Make the childGroup's transform match the requested viewRange. + + #if changed is None: + #changed = [False, False] + #changed = list(changed) + #tr = self.targetRect() + #bounds = self.rect() + + ## set viewRect, given targetRect and possibly aspect ratio constraint + #self.state['viewRange'] = [self.state['targetRange'][0][:], self.state['targetRange'][1][:]] + + #aspect = self.state['aspectLocked'] + #if aspect is False or bounds.height() == 0: + #self.state['viewRange'] = [self.state['targetRange'][0][:], self.state['targetRange'][1][:]] + #else: + ### aspect is (widget w/h) / (view range w/h) + + ### This is the view range aspect ratio we have requested + #targetRatio = tr.width() / tr.height() + ### This is the view range aspect ratio we need to obey aspect constraint + #viewRatio = (bounds.width() / bounds.height()) / aspect + + #if targetRatio > viewRatio: + ### view range needs to be taller than target + #dy = 0.5 * (tr.width() / viewRatio - tr.height()) + #if dy != 0: + #changed[1] = True + #self.state['viewRange'] = [ + #self.state['targetRange'][0][:], + #[self.state['targetRange'][1][0] - dy, self.state['targetRange'][1][1] + dy] + #] + #else: + ### view range needs to be wider than target + #dx = 0.5 * (tr.height() * viewRatio - tr.width()) + #if dx != 0: + #changed[0] = True + #self.state['viewRange'] = [ + #[self.state['targetRange'][0][0] - dx, self.state['targetRange'][0][1] + dx], + #self.state['targetRange'][1][:] + #] vr = self.viewRect() if vr.height() == 0 or vr.width() == 0: @@ -1294,15 +1431,16 @@ class ViewBox(GraphicsWidget): self.childGroup.setTransform(m) - if changed[0]: - self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][0])) - if changed[1]: - self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][1])) - if any(changed): - self.sigRangeChanged.emit(self, self.state['viewRange']) + # moved to viewRangeChanged + #if changed[0]: + #self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][0])) + #if changed[1]: + #self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][1])) + #if any(changed): + #self.sigRangeChanged.emit(self, self.state['viewRange']) self.sigTransformChanged.emit(self) ## segfaults here: 1 - self.matrixNeedsUpdate = False + self._matrixNeedsUpdate = False def paint(self, p, opt, widget): if self.border is not None: diff --git a/pyqtgraph/graphicsItems/tests/ViewBox.py b/pyqtgraph/graphicsItems/tests/ViewBox.py index a04e9a29..91d9b617 100644 --- a/pyqtgraph/graphicsItems/tests/ViewBox.py +++ b/pyqtgraph/graphicsItems/tests/ViewBox.py @@ -15,41 +15,58 @@ ViewBox test cases: import pyqtgraph as pg app = pg.mkQApp() -win = pg.GraphicsWindow() -vb = win.addViewBox(name="image view") -vb.setAspectLocked() -p1 = win.addPlot(name="plot 1") -p2 = win.addPlot(name="plot 2", row=1, col=0) -win.ci.layout.setRowFixedHeight(1, 150) -win.ci.layout.setColumnFixedWidth(1, 150) -def viewsMatch(): - r0 = pg.np.array(vb.viewRange()) - r1 = pg.np.array(p1.vb.viewRange()[1]) - r2 = pg.np.array(p2.vb.viewRange()[1]) - match = (abs(r0[1]-r1) <= (abs(r1) * 0.001)).all() and (abs(r0[0]-r2) <= (abs(r2) * 0.001)).all() - return match - -p1.setYLink(vb) -p2.setXLink(vb) -print "link views match:", viewsMatch() -win.show() -print "show views match:", viewsMatch() imgData = pg.np.zeros((10, 10)) -imgData[0] = 1 -imgData[-1] = 1 -imgData[:,0] = 1 -imgData[:,-1] = 1 -img = pg.ImageItem(imgData) -vb.addItem(img) -p1.plot(x=imgData.sum(axis=0), y=range(10)) -p2.plot(x=range(10), y=imgData.sum(axis=1)) -print "add items views match:", viewsMatch() -#p1.setAspectLocked() -#grid = pg.GridItem() -#vb.addItem(grid) +imgData[0] = 3 +imgData[-1] = 3 +imgData[:,0] = 3 +imgData[:,-1] = 3 +def testLinkWithAspectLock(): + global win, vb + win = pg.GraphicsWindow() + vb = win.addViewBox(name="image view") + vb.setAspectLocked() + vb.enableAutoRange(x=False, y=False) + p1 = win.addPlot(name="plot 1") + p2 = win.addPlot(name="plot 2", row=1, col=0) + win.ci.layout.setRowFixedHeight(1, 150) + win.ci.layout.setColumnFixedWidth(1, 150) + def viewsMatch(): + r0 = pg.np.array(vb.viewRange()) + r1 = pg.np.array(p1.vb.viewRange()[1]) + r2 = pg.np.array(p2.vb.viewRange()[1]) + match = (abs(r0[1]-r1) <= (abs(r1) * 0.001)).all() and (abs(r0[0]-r2) <= (abs(r2) * 0.001)).all() + return match + + p1.setYLink(vb) + p2.setXLink(vb) + print "link views match:", viewsMatch() + win.show() + print "show views match:", viewsMatch() + img = pg.ImageItem(imgData) + vb.addItem(img) + vb.autoRange() + p1.plot(x=imgData.sum(axis=0), y=range(10)) + p2.plot(x=range(10), y=imgData.sum(axis=1)) + print "add items views match:", viewsMatch() + #p1.setAspectLocked() + #grid = pg.GridItem() + #vb.addItem(grid) + pg.QtGui.QApplication.processEvents() + pg.QtGui.QApplication.processEvents() + #win.resize(801, 600) + +def testAspectLock(): + global win, vb + win = pg.GraphicsWindow() + vb = win.addViewBox(name="image view") + vb.setAspectLocked() + img = pg.ImageItem(imgData) + vb.addItem(img) + + #app.processEvents() #print "init views match:", viewsMatch() #p2.setYRange(-300, 300) @@ -72,3 +89,7 @@ print "add items views match:", viewsMatch() #win.resize(600, 100) #app.processEvents() #print vb.viewRange() + + +if __name__ == '__main__': + testLinkWithAspectLock()