ViewBox.py 40.3 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from pyqtgraph.Qt import QtGui, QtCore
import numpy as np
from pyqtgraph.Point import Point
import pyqtgraph.functions as fn
from .. ItemGroup import ItemGroup
from .. GraphicsWidget import GraphicsWidget
from pyqtgraph.GraphicsScene import GraphicsScene
import pyqtgraph
import weakref
from copy import deepcopy
import collections

__all__ = ['ViewBox']


class ChildGroup(ItemGroup):
    
    sigItemsChanged = QtCore.Signal()
    
    def itemChange(self, change, value):
        ret = ItemGroup.itemChange(self, change, value)
        if change == self.ItemChildAddedChange or change == self.ItemChildRemovedChange:
            self.sigItemsChanged.emit()
        
        return ret


class ViewBox(GraphicsWidget):
    """
Luke Campagnola's avatar
Luke Campagnola committed
30
31
    **Bases:** :class:`GraphicsWidget <pyqtgraph.GraphicsWidget>`
    
32
    Box that allows internal scaling/panning of children by mouse drag. 
Luke Campagnola's avatar
Luke Campagnola committed
33
34
35
36
37
38
39
40
41
    This class is usually created automatically as part of a :class:`PlotItem <pyqtgraph.PlotItem>` or :class:`Canvas <pyqtgraph.canvas.Canvas>` or with :func:`GraphicsLayout.addViewBox() <pyqtgraph.GraphicsLayout.addViewBox>`.
    
    Features:
    
        - Scaling contents by mouse or auto-scale when contents change
        - View linking--multiple views display the same data ranges
        - Configurable by context menu
        - Item coordinate mapping methods
    
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
    Not really compatible with GraphicsView having the same functionality.
    """
    
    sigYRangeChanged = QtCore.Signal(object, object)
    sigXRangeChanged = QtCore.Signal(object, object)
    sigRangeChangedManually = QtCore.Signal(object)
    sigRangeChanged = QtCore.Signal(object, object)
    #sigActionPositionChanged = QtCore.Signal(object)
    sigStateChanged = QtCore.Signal(object)
    
    ## mouse modes
    PanMode = 3
    RectMode = 1
    
    ## axes
    XAxis = 0
    YAxis = 1
    XYAxes = 2
    
    ## for linking views together
    NamedViews = weakref.WeakValueDictionary()   # name: ViewBox
    AllViews = weakref.WeakKeyDictionary()       # ViewBox: None
    
    
    def __init__(self, parent=None, border=None, lockAspect=False, enableMouse=True, invertY=False, name=None):
Luke Campagnola's avatar
Luke Campagnola committed
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
        """
        =============  =============================================================
        **Arguments**
        *parent*       (QGraphicsWidget) Optional parent widget
        *border*       (QPen) Do draw a border around the view, give any 
                       single argument accepted by :func:`mkPen <pyqtgraph.mkPen>`
        *lockAspect*   (False or float) The aspect ratio to lock the view 
                       coorinates to. (or False to allow the ratio to change)
        *enableMouse*  (bool) Whether mouse can be used to scale/pan the view 
        *invertY*      (bool) See :func:`invertY <pyqtgraph.ViewBox.invertY>`
        =============  =============================================================
        """
        
        
        
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
        GraphicsWidget.__init__(self, parent)
        self.name = None
        self.linksBlocked = False
        self.addedItems = []
        #self.gView = view
        #self.showGrid = showGrid
        
        self.state = {
            
            ## separating targetRange and viewRange allows the view to be resized
            ## while keeping all previously viewed contents visible
            'targetRange': [[0,1], [0,1]],   ## child coord. range visible [[xmin, xmax], [ymin, ymax]]
            'viewRange': [[0,1], [0,1]],     ## actual range viewed
        
            'yInverted': invertY,
            'aspectLocked': False,    ## False if aspect is unlocked, otherwise float specifies the locked ratio.
            'autoRange': [True, True],  ## False if auto range is disabled, 
                                          ## otherwise float gives the fraction of data that is visible
            'linkedViews': [None, None],
            
            'mouseEnabled': [enableMouse, enableMouse],
            'mouseMode': ViewBox.PanMode if pyqtgraph.getConfigOption('leftButtonPan') else ViewBox.RectMode,  
            'wheelScaleFactor': -1.0 / 8.0,
        }
        
        
Luke Campagnola's avatar
bugfix    
Luke Campagnola committed
108
109
110
111
112
        #self.exportMethods = collections.OrderedDict([
            #('SVG', self.saveSvg),
            #('Image', self.saveImage),
            #('Print', self.savePrint),
        #])
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
        
        self.setFlag(self.ItemClipsChildrenToShape)
        self.setFlag(self.ItemIsFocusable, True)  ## so we can receive key presses
        
        ## childGroup is required so that ViewBox has local coordinates similar to device coordinates.
        ## this is a workaround for a Qt + OpenGL but that causes improper clipping
        ## https://bugreports.qt.nokia.com/browse/QTBUG-23723
        self.childGroup = ChildGroup(self)
        self.childGroup.sigItemsChanged.connect(self.itemsChanged)
        
        #self.useLeftButtonPan = pyqtgraph.getConfigOption('leftButtonPan') # normally use left button to pan
        # this also enables capture of keyPressEvents.
        
        ## Make scale box that is shown when dragging on the view
        self.rbScaleBox = QtGui.QGraphicsRectItem(0, 0, 1, 1)
        self.rbScaleBox.setPen(fn.mkPen((255,0,0), width=1))
        self.rbScaleBox.setBrush(fn.mkBrush(255,255,0,100))
        self.rbScaleBox.hide()
131
        self.addItem(self.rbScaleBox)
132
133
134
135
136
137
138
139
140
        
        self.axHistory = [] # maintain a history of zoom locations
        self.axHistoryPointer = -1 # pointer into the history. Allows forward/backward movement, not just "undo"
        
        self.setZValue(-100)
        self.setSizePolicy(QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding))
        
        self.setAspectLocked(lockAspect)
        
Luke Campagnola's avatar
Luke Campagnola committed
141
        self.border = fn.mkPen(border)
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
        self.menu = ViewBoxMenu(self)
        
        self.register(name)
        if name is None:
            self.updateViewLists()
        
    def register(self, name):
        """
        Add this ViewBox to the registered list of views. 
        *name* will appear in the drop-down lists for axis linking in all other views.
        The same can be accomplished by initializing the ViewBox with the *name* attribute.
        """
        ViewBox.AllViews[self] = None
        if self.name is not None:
            del ViewBox.NamedViews[self.name]
        self.name = name
        if name is not None:
            ViewBox.NamedViews[name] = self
            ViewBox.updateAllViewLists()

    def unregister(self):
Luke Campagnola's avatar
Luke Campagnola committed
163
164
165
        """
        Remove this ViewBox forom the list of linkable views. (see :func:`register() <pyqtgraph.ViewBox.register>`)
        """
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
        del ViewBox.AllViews[self]
        if self.name is not None:
            del ViewBox.NamedViews[self.name]

    def close(self):
        self.unregister()

    def implements(self, interface):
        return interface == 'ViewBox'
        
        
    def getState(self, copy=True):
        state = self.state.copy()
        state['linkedViews'] = [(None if v is None else v.name) for v in state['linkedViews']]
        if copy:
            return deepcopy(self.state)
        else:
            return self.state
        
    def setState(self, state):
        state = state.copy()
        self.setXLink(state['linkedViews'][0])
        self.setYLink(state['linkedViews'][1])
        del state['linkedViews']
        
        self.state.update(state)
        self.updateMatrix()
        self.sigStateChanged.emit(self)


    def setMouseMode(self, mode):
Luke Campagnola's avatar
Luke Campagnola committed
197
198
199
200
201
        """
        Set the mouse interaction mode. *mode* must be either ViewBox.PanMode or ViewBox.RectMode.
        In PanMode, the left mouse button pans the view and the right button scales.
        In RectMode, the left button draws a rectangle which updates the visible region (this mode is more suitable for single-button mice)
        """
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
        if mode not in [ViewBox.PanMode, ViewBox.RectMode]:
            raise Exception("Mode must be ViewBox.PanMode or ViewBox.RectMode")
        self.state['mouseMode'] = mode
        self.sigStateChanged.emit(self)

    #def toggleLeftAction(self, act):  ## for backward compatibility
        #if act.text() is 'pan':
            #self.setLeftButtonAction('pan')
        #elif act.text() is 'zoom':
            #self.setLeftButtonAction('rect')

    def setLeftButtonAction(self, mode='rect'):  ## for backward compatibility
        if mode.lower() == 'rect':
            self.setMouseMode(ViewBox.RectMode)
        elif mode.lower() == 'pan':
            self.setMouseMode(ViewBox.PanMode)
        else:
            raise Exception('graphicsItems:ViewBox:setLeftButtonAction: unknown mode = %s (Options are "pan" and "rect")' % mode)
            
    def innerSceneItem(self):
        return self.childGroup
    
    def setMouseEnabled(self, x=None, y=None):
Luke Campagnola's avatar
Luke Campagnola committed
225
226
227
228
        """
        Set whether each axis is enabled for mouse interaction. *x*, *y* arguments must be True or False.
        This allows the user to pan/scale one axis of the view while leaving the other axis unchanged.
        """
229
230
231
232
233
234
235
236
237
        if x is not None:
            self.state['mouseEnabled'][0] = x
        if y is not None:
            self.state['mouseEnabled'][1] = y
        self.sigStateChanged.emit(self)
            
    def mouseEnabled(self):
        return self.state['mouseEnabled'][:]
    
238
    def addItem(self, item, ignoreBounds=False):
Luke Campagnola's avatar
Luke Campagnola committed
239
240
241
242
        """
        Add a QGraphicsItem to this view. The view will include this item when determining how to set its range
        automatically unless *ignoreBounds* is True.
        """
243
244
245
        if item.zValue() < self.zValue():
            item.setZValue(self.zValue()+1)
        item.setParentItem(self.childGroup)
246
247
        if not ignoreBounds:
            self.addedItems.append(item)
248
249
250
251
        self.updateAutoRange()
        #print "addItem:", item, item.boundingRect()
        
    def removeItem(self, item):
Luke Campagnola's avatar
Luke Campagnola committed
252
        """Remove an item from this view."""
Luke Campagnola's avatar
Luke Campagnola committed
253
254
255
256
        try:
            self.addedItems.remove(item)
        except:
            pass
257
258
259
260
261
        self.scene().removeItem(item)
        self.updateAutoRange()

    def resizeEvent(self, ev):
        #self.setRange(self.range, padding=0)
Luke Campagnola's avatar
Luke Campagnola committed
262
        #self.updateAutoRange()
263
264
        self.updateMatrix()
        self.sigStateChanged.emit(self)
265
266
        #self.linkedXChanged()
        #self.linkedYChanged()
267
268
        
    def viewRange(self):
Luke Campagnola's avatar
Luke Campagnola committed
269
        """Return a the view's visible range as a list: [[xmin, xmax], [ymin, ymax]]"""
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
        return [x[:] for x in self.state['viewRange']]  ## return copy

    def viewRect(self):
        """Return a QRectF bounding the region visible within the ViewBox"""
        try:
            vr0 = self.state['viewRange'][0]
            vr1 = self.state['viewRange'][1]
            return QtCore.QRectF(vr0[0], vr1[0], vr0[1]-vr0[0], vr1[1] - vr1[0])
        except:
            print "make qrectf failed:", self.state['viewRange']
            raise
    
    #def viewportTransform(self):
        ##return self.itemTransform(self.childGroup)[0]
        #return self.childGroup.itemTransform(self)[0]
    
    def targetRange(self):
        return [x[:] for x in self.state['targetRange']]  ## return copy
    
    def targetRect(self):  
        """
        Return the region which has been requested to be visible. 
        (this is not necessarily the same as the region that is *actually* visible--
        resizing and aspect ratio constraints can cause targetRect() and viewRect() to differ)
        """
        try:
            tr0 = self.state['targetRange'][0]
            tr1 = self.state['targetRange'][1]
            return QtCore.QRectF(tr0[0], tr1[0], tr0[1]-tr0[0], tr1[1] - tr1[0])
        except:
            print "make qrectf failed:", self.state['targetRange']
            raise

    def setRange(self, rect=None, xRange=None, yRange=None, padding=0.02, update=True, disableAutoRange=True):
        """
        Set the visible range of the ViewBox.
        Must specify at least one of *range*, *xRange*, or *yRange*. 
        
Luke Campagnola's avatar
Luke Campagnola committed
308
309
310
311
312
313
314
315
        ============= =====================================================================
        **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 0.02 (2%)
        ============= =====================================================================
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
        
        """
        changes = {}
        
        if rect is not None:
            changes = {0: [rect.left(), rect.right()], 1: [rect.top(), rect.bottom()]}
        if xRange is not None:
            changes[0] = xRange
        if yRange is not None:
            changes[1] = yRange

        if len(changes) == 0:
            raise Exception("Must specify at least one of rect, xRange, or yRange.")
        
        changed = [False, False]
        for ax, range in changes.iteritems():
            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.
                dy = self.state['viewRange'][ax][1] - self.state['viewRange'][ax][0]
                if dy == 0:
                    dy = 1
                mn -= dy*0.5
                mx += dy*0.5
                padding = 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)))
                
            p = (mx-mn) * padding
            mn -= p
            mx += p
            
            if self.state['targetRange'][ax] != [mn, mx]:
                self.state['targetRange'][ax] = [mn, mx]
                changed[ax] = True
            
        if any(changed) and disableAutoRange:
            if all(changed):
                ax = ViewBox.XYAxes
            elif changed[0]:
                ax = ViewBox.XAxis
            elif changed[1]:
                ax = ViewBox.YAxis
            self.enableAutoRange(ax, False)
                
                
        self.sigStateChanged.emit(self)
        
        if update:
            self.updateMatrix(changed)
            
        for ax, range in changes.iteritems():
            link = self.state['linkedViews'][ax]
            if link is not None:
                link.linkedViewChanged(self, ax)
        

            
    def setYRange(self, min, max, padding=0.02, update=True):
Luke Campagnola's avatar
Luke Campagnola committed
375
376
377
378
        """
        Set the visible Y range of the view to [*min*, *max*]. 
        The *padding* argument causes the range to be set larger by the fraction specified.
        """
379
380
381
        self.setRange(yRange=[min, max], update=update, padding=padding)
        
    def setXRange(self, min, max, padding=0.02, update=True):
Luke Campagnola's avatar
Luke Campagnola committed
382
383
384
385
        """
        Set the visible X range of the view to [*min*, *max*]. 
        The *padding* argument causes the range to be set larger by the fraction specified.
        """
386
387
388
389
390
        self.setRange(xRange=[min, max], update=update, padding=padding)

    def autoRange(self, padding=0.02):
        """
        Set the range of the view box to make all children visible.
Luke Campagnola's avatar
Luke Campagnola committed
391
392
        Note that this is not the same as enableAutoRange, which causes the view to 
        automatically auto-range whenever its contents are changed.
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
        """
        bounds = self.childrenBoundingRect()
        if bounds is not None:
            self.setRange(bounds, padding=padding)
            
            
    def scaleBy(self, s, center=None):
        """
        Scale by *s* around given center point (or center of view).
        *s* may be a Point or tuple (x, y)
        """
        scale = Point(s)
        if self.state['aspectLocked'] is not False:
            scale[0] = self.state['aspectLocked'] * scale[1]

        vr = self.targetRect()
        if center is None:
            center = Point(vr.center())
        else:
            center = Point(center)
        
        tl = center + (vr.topLeft()-center) * scale
        br = center + (vr.bottomRight()-center) * scale
       
        self.setRange(QtCore.QRectF(tl, br), padding=0)
        
    def translateBy(self, t):
        """
        Translate the view by *t*, which may be a Point or tuple (x, y).
        """
        t = Point(t)
        #if viewCoords:  ## scale from pixels
            #o = self.mapToView(Point(0,0))
            #t = self.mapToView(t) - o
        
        vr = self.targetRect()
        self.setRange(vr.translated(t), padding=0)
        
    def enableAutoRange(self, axis=None, enable=True):
        """
        Enable (or disable) auto-range for *axis*, which may be ViewBox.XAxis, ViewBox.YAxis, or ViewBox.XYAxes for both.
        When enabled, the axis will automatically rescale when items are added/removed or change their shape.
        The argument *enable* may optionally be a float (0.0-1.0) which indicates the fraction of the data that should
        be visible (this only works with items implementing a dataRange method, such as PlotDataItem).
        """
        #print "autorange:", axis, enable
        #if not enable:
            #import traceback
            #traceback.print_stack()
        if enable is True:
            enable = 1.0
        
        if axis is None:
            axis = ViewBox.XYAxes
        
        if axis == ViewBox.XYAxes or axis == 'xy':
            self.state['autoRange'][0] = enable
            self.state['autoRange'][1] = enable
        elif axis == ViewBox.XAxis or axis == 'x':
            self.state['autoRange'][0] = enable
        elif axis == ViewBox.YAxis or axis == 'y':
            self.state['autoRange'][1] = enable
        else:
            raise Exception('axis argument must be ViewBox.XAxis, ViewBox.YAxis, or ViewBox.XYAxes.')
        
        if enable:
            self.updateAutoRange()
        self.sigStateChanged.emit(self)

    def disableAutoRange(self, axis=None):
Luke Campagnola's avatar
Luke Campagnola committed
463
        """Disables auto-range. (See enableAutoRange)"""
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
        self.enableAutoRange(axis, enable=False)

    def autoRangeEnabled(self):
        return self.state['autoRange'][:]

    def updateAutoRange(self):
        tr = self.viewRect()
        if not any(self.state['autoRange']):
            return
            
        fractionVisible = self.state['autoRange'][:]
        for i in [0,1]:
            if type(fractionVisible[i]) is bool:
                fractionVisible[i] = 1.0
        cr = self.childrenBoundingRect(frac=fractionVisible)
        wp = cr.width() * 0.02
        hp = cr.height() * 0.02
        cr = cr.adjusted(-wp, -hp, wp, hp)
        
        if self.state['autoRange'][0] is not False:
            tr.setLeft(cr.left())
            tr.setRight(cr.right())
        if self.state['autoRange'][1] is not False:
            tr.setTop(cr.top())
            tr.setBottom(cr.bottom())
            
        self.setRange(tr, padding=0, disableAutoRange=False)
        
    def setXLink(self, view):
Luke Campagnola's avatar
Luke Campagnola committed
493
        """Link this view's X axis to another view. (see LinkView)"""
494
495
496
        self.linkView(self.XAxis, view)
        
    def setYLink(self, view):
Luke Campagnola's avatar
Luke Campagnola committed
497
        """Link this view's Y axis to another view. (see LinkView)"""
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
        self.linkView(self.YAxis, view)
        
        
    def linkView(self, axis, view):
        """
        Link X or Y axes of two views and unlink any previously connected axes. *axis* must be ViewBox.XAxis or ViewBox.YAxis.
        If view is None, the axis is left unlinked.
        """
        if isinstance(view, basestring):
            if view == '':
                view = None
            else:
                view = ViewBox.NamedViews[view]

        if hasattr(view, 'implements') and view.implements('ViewBoxWrapper'):
            view = view.getViewBox()

        ## used to connect/disconnect signals between a pair of views
        if axis == ViewBox.XAxis:
            signal = 'sigXRangeChanged'
            slot = self.linkedXChanged
        else:
            signal = 'sigYRangeChanged'
            slot = self.linkedYChanged


        oldLink = self.state['linkedViews'][axis]
        if oldLink is not None:
            getattr(oldLink, signal).disconnect(slot)
            
        self.state['linkedViews'][axis] = view
        
        if view is not None:
            getattr(view, signal).connect(slot)
532
            if view.autoRangeEnabled()[axis] is not False:
533
534
535
536
537
538
539
540
541
542
543
544
                self.enableAutoRange(axis, False)
                slot()
            else:
                if self.autoRangeEnabled()[axis] is False:
                    slot()
            
        self.sigStateChanged.emit(self)
        
    def blockLink(self, b):
        self.linksBlocked = b  ## prevents recursive plot-change propagation

    def linkedXChanged(self):
545
        ## called when x range of linked view has changed
546
547
548
549
        view = self.state['linkedViews'][0]
        self.linkedViewChanged(view, ViewBox.XAxis)

    def linkedYChanged(self):
550
551
        ## called when y range of linked view has changed
        view = self.state['linkedViews'][1]
552
553
554
555
        self.linkedViewChanged(view, ViewBox.YAxis)
        

    def linkedViewChanged(self, view, axis):
556
        if self.linksBlocked or view is None:
557
558
559
560
561
562
563
564
565
566
567
568
            return
        
        vr = view.viewRect()
        vg = view.screenGeometry()
        if vg is None:
            return
            
        sg = self.screenGeometry()
        
        view.blockLink(True)
        try:
            if axis == ViewBox.XAxis:
569
570
571
572
573
574
575
576
577
                overlap = min(sg.right(), vg.right()) - max(sg.left(), vg.left())
                if overlap < min(vg.width()/3, sg.width()/3):  ## if less than 1/3 of views overlap, 
                                                               ## then just replicate the view
                    x1 = vr.left()
                    x2 = vr.right()
                else:  ## views overlap; line them up
                    upp = float(vr.width()) / vg.width()
                    x1 = vr.left() + (sg.x()-vg.x()) * upp
                    x2 = x1 + sg.width() * upp
578
579
580
                self.enableAutoRange(ViewBox.XAxis, False)
                self.setXRange(x1, x2, padding=0)
            else:
581
582
583
584
585
586
587
588
589
                overlap = min(sg.bottom(), vg.bottom()) - max(sg.top(), vg.top())
                if overlap < min(vg.height()/3, sg.height()/3):  ## if less than 1/3 of views overlap, 
                                                               ## then just replicate the view
                    x1 = vr.top()
                    x2 = vr.bottom()
                else:  ## views overlap; line them up
                    upp = float(vr.height()) / vg.height()
                    x1 = vr.top() + (sg.y()-vg.y()) * upp
                    x2 = x1 + sg.height() * upp
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
                self.enableAutoRange(ViewBox.YAxis, False)
                self.setYRange(x1, x2, padding=0)
        finally:
            view.blockLink(False)
        
        
    def screenGeometry(self):
        """return the screen geometry of the viewbox"""
        v = self.getViewWidget()
        if v is None:
            return None
        b = self.sceneBoundingRect()
        wr = v.mapFromScene(b).boundingRect()
        pos = v.mapToGlobal(v.pos())
        wr.adjust(pos.x(), pos.y(), pos.x(), pos.y())
        return wr
        
    

    def itemsChanged(self):
        ## called when items are added/removed from self.childGroup
        self.updateAutoRange()
        
    def itemBoundsChanged(self, item):
        self.updateAutoRange()

    def invertY(self, b=True):
        """
        By default, the positive y-axis points upward on the screen. Use invertY(True) to reverse the y-axis.
        """
        self.state['yInverted'] = b
        self.updateMatrix()
        self.sigStateChanged.emit(self)
        
    def setAspectLocked(self, lock=True, ratio=1):
        """
        If the aspect ratio is locked, view scaling must always preserve the aspect ratio.
        By default, the ratio is set to 1; x and y both have the same scaling.
        This ratio can be overridden (width/height), or use None to lock in the current ratio.
        """
        if not lock:
            self.state['aspectLocked'] = False
        else:
            vr = self.viewRect()
            currentRatio = vr.width() / vr.height()
            if ratio is None:
                ratio = currentRatio
            self.state['aspectLocked'] = ratio
            if ratio != currentRatio:  ## If this would change the current range, do that now
                #self.setRange(0, self.state['viewRange'][0][0], self.state['viewRange'][0][1])
                self.updateMatrix()
        self.sigStateChanged.emit(self)
        
    def childTransform(self):
        """
        Return the transform that maps from child(item in the childGroup) coordinates to local coordinates.
        (This maps from inside the viewbox to outside)
        """ 
        m = self.childGroup.transform()
        #m1 = QtGui.QTransform()
        #m1.translate(self.childGroup.pos().x(), self.childGroup.pos().y())
        return m #*m1

    def mapToView(self, obj):
        """Maps from the local coordinates of the ViewBox to the coordinate system displayed inside the ViewBox"""
        m = self.childTransform().inverted()[0]
        return m.map(obj)

    def mapFromView(self, obj):
        """Maps from the coordinate system displayed inside the ViewBox to the local coordinates of the ViewBox"""
        m = self.childTransform()
        return m.map(obj)

    def mapSceneToView(self, obj):
        """Maps from scene coordinates to the coordinate system displayed inside the ViewBox"""
        return self.mapToView(self.mapFromScene(obj))

    def mapViewToScene(self, obj):
        """Maps from the coordinate system displayed inside the ViewBox to scene coordinates"""
        return self.mapToScene(self.mapFromView(obj))
    
    def mapFromItemToView(self, item, obj):
Luke Campagnola's avatar
Luke Campagnola committed
672
        """Maps *obj* from the local coordinate system of *item* to the view coordinates"""
Luke Campagnola's avatar
Luke Campagnola committed
673
674
        return self.childGroup.mapFromItem(item, obj)
        #return self.mapSceneToView(item.mapToScene(obj))
675
676

    def mapFromViewToItem(self, item, obj):
Luke Campagnola's avatar
Luke Campagnola committed
677
        """Maps *obj* from view coordinates to the local coordinate system of *item*."""
Luke Campagnola's avatar
Luke Campagnola committed
678
679
        return self.childGroup.mapToItem(item, obj)
        #return item.mapFromScene(self.mapViewToScene(obj))
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744

    def itemBoundingRect(self, item):
        """Return the bounding rect of the item in view coordinates"""
        return self.mapSceneToView(item.sceneBoundingRect()).boundingRect()
    
    #def viewScale(self):
        #vr = self.viewRect()
        ##print "viewScale:", self.range
        #xd = vr.width()
        #yd = vr.height()
        #if xd == 0 or yd == 0:
            #print "Warning: 0 range in view:", xd, yd
            #return np.array([1,1])
        
        ##cs = self.canvas().size()
        #cs = self.boundingRect()
        #scale = np.array([cs.width() / xd, cs.height() / yd])
        ##print "view scale:", scale
        #return scale

    def wheelEvent(self, ev, axis=None):
        mask = np.array(self.state['mouseEnabled'], dtype=np.float)
        if axis is not None and axis >= 0 and axis < len(mask):
            mv = mask[axis]
            mask[:] = 0
            mask[axis] = mv
        s = ((mask * 0.02) + 1) ** (ev.delta() * self.state['wheelScaleFactor']) # actual scaling factor
        
        center = Point(self.childGroup.transform().inverted()[0].map(ev.pos()))
        #center = ev.pos()
        
        self.scaleBy(s, center)
        self.sigRangeChangedManually.emit(self.state['mouseEnabled'])
        ev.accept()

        
    def mouseClickEvent(self, ev):
        if ev.button() == QtCore.Qt.RightButton:
            ev.accept()
            self.raiseContextMenu(ev)
    
    def raiseContextMenu(self, ev):
        #print "viewbox.raiseContextMenu called."
        
        #menu = self.getMenu(ev)
        menu = self.getMenu(ev)
        self.scene().addParentContextMenus(self, menu, ev)
        #print "2:", [str(a.text()) for a in self.menu.actions()]
        pos = ev.screenPos()
        #pos2 = ev.scenePos()
        #print "3:", [str(a.text()) for a in self.menu.actions()]
        #self.sigActionPositionChanged.emit(pos2)

        menu.popup(QtCore.QPoint(pos.x(), pos.y()))
        #print "4:", [str(a.text()) for a in self.menu.actions()]
        
    def getMenu(self, ev):
        self._menuCopy = self.menu.copy()  ## temporary storage to prevent menu disappearing
        return self._menuCopy
        
    def getContextMenus(self, event):
        return self.menu.subMenus()
        #return [self.getMenu(event)]
        

Luke Campagnola's avatar
Luke Campagnola committed
745
746
    def mouseDragEvent(self, ev, axis=None):
        ## if axis is specified, event will only affect that axis.
747
748
749
750
751
752
753
754
755
        ev.accept()  ## we accept all buttons
        
        pos = ev.pos()
        lastPos = ev.lastPos()
        dif = pos - lastPos
        dif = dif * -1

        ## Ignore axes if mouse is disabled
        mask = np.array(self.state['mouseEnabled'], dtype=np.float)
Luke Campagnola's avatar
Luke Campagnola committed
756
757
        if axis is not None:
            mask[1-axis] = 0.0
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884

        ## Scale or translate based on mouse button
        if ev.button() & (QtCore.Qt.LeftButton | QtCore.Qt.MidButton):
            if self.state['mouseMode'] == ViewBox.RectMode:
                if ev.isFinish():  ## This is the final move in the drag; change the view scale now
                    #print "finish"
                    self.rbScaleBox.hide()
                    #ax = QtCore.QRectF(Point(self.pressPos), Point(self.mousePos))
                    ax = QtCore.QRectF(Point(ev.buttonDownPos(ev.button())), Point(pos))
                    ax = self.childGroup.mapRectFromParent(ax)
                    self.showAxRect(ax)
                    self.axHistoryPointer += 1
                    self.axHistory = self.axHistory[:self.axHistoryPointer] + [ax]
                else:
                    ## update shape of scale box
                    self.updateScaleBox(ev.buttonDownPos(), ev.pos())
            else:
                tr = dif*mask
                tr = self.mapToView(tr) - self.mapToView(Point(0,0))
                self.translateBy(tr)
                self.sigRangeChangedManually.emit(self.state['mouseEnabled'])
        elif ev.button() & QtCore.Qt.RightButton:
            #print "vb.rightDrag"
            if self.state['aspectLocked'] is not False:
                mask[0] = 0
            
            dif = ev.screenPos() - ev.lastScreenPos()
            dif = np.array([dif.x(), dif.y()])
            dif[0] *= -1
            s = ((mask * 0.02) + 1) ** dif
            center = Point(self.childGroup.transform().inverted()[0].map(ev.buttonDownPos(QtCore.Qt.RightButton)))
            #center = Point(ev.buttonDownPos(QtCore.Qt.RightButton))
            self.scaleBy(s, center)
            self.sigRangeChangedManually.emit(self.state['mouseEnabled'])

    def keyPressEvent(self, ev):
        """
        This routine should capture key presses in the current view box.
        Key presses are used only when mouse mode is RectMode
        The following events are implemented:
        ctrl-A : zooms out to the default "full" view of the plot
        ctrl-+ : moves forward in the zooming stack (if it exists)
        ctrl-- : moves backward in the zooming stack (if it exists)
         
        """
        #print ev.key()
        #print 'I intercepted a key press, but did not accept it'
        
        ## not implemented yet ?
        #self.keypress.sigkeyPressEvent.emit()
        
        ev.accept()
        if ev.text() == '-':
            self.scaleHistory(-1)
        elif ev.text() in ['+', '=']:
            self.scaleHistory(1)
        elif ev.key() == QtCore.Qt.Key_Backspace:
            self.scaleHistory(len(self.axHistory))
        else:
            ev.ignore()

    def scaleHistory(self, d):
        ptr = max(0, min(len(self.axHistory)-1, self.axHistoryPointer+d))
        if ptr != self.axHistoryPointer:
            self.axHistoryPointer = ptr
            self.showAxRect(self.axHistory[ptr])
            

    def updateScaleBox(self, p1, p2):
        r = QtCore.QRectF(p1, p2)
        r = self.childGroup.mapRectFromParent(r)
        self.rbScaleBox.setPos(r.topLeft())
        self.rbScaleBox.resetTransform()
        self.rbScaleBox.scale(r.width(), r.height())
        self.rbScaleBox.show()

    def showAxRect(self, ax):
        self.setRange(ax.normalized()) # be sure w, h are correct coordinates
        self.sigRangeChangedManually.emit(self.state['mouseEnabled'])

    #def mouseRect(self):
        #vs = self.viewScale()
        #vr = self.state['viewRange']
        ## Convert positions from screen (view) pixel coordinates to axis coordinates 
        #ax = QtCore.QRectF(self.pressPos[0]/vs[0]+vr[0][0], -(self.pressPos[1]/vs[1]-vr[1][1]),
            #(self.mousePos[0]-self.pressPos[0])/vs[0], -(self.mousePos[1]-self.pressPos[1])/vs[1])
        #return(ax)

    def allChildren(self, item=None):
        """Return a list of all children and grandchildren of this ViewBox"""
        if item is None:
            item = self.childGroup
        
        children = [item]
        for ch in item.childItems():
            children.extend(self.allChildren(ch))
        return children
        
        
        
    def childrenBoundingRect(self, frac=None):
        """Return the bounding range of all children.
        [[xmin, xmax], [ymin, ymax]]
        Values may be None if there are no specific bounds for an axis.
        """
        
        #items = self.allChildren()
        items = self.addedItems
        
        #if item is None:
            ##print "children bounding rect:"
            #item = self.childGroup
            
        range = [None, None]
            
        for item in items:
            if not item.isVisible():
                continue
        
            #print "=========", item
            useX = True
            useY = True
            if hasattr(item, 'dataBounds'):
                if frac is None:
                    frac = (1.0, 1.0)
                xr = item.dataBounds(0, frac=frac[0])
                yr = item.dataBounds(1, frac=frac[1])
Luke Campagnola's avatar
Luke Campagnola committed
885
                if xr is None or xr == (None, None):
886
887
                    useX = False
                    xr = (0,0)
Luke Campagnola's avatar
Luke Campagnola committed
888
                if yr is None or yr == (None, None):
889
890
                    useY = False
                    yr = (0,0)
Luke Campagnola's avatar
Luke Campagnola committed
891

892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
                bounds = QtCore.QRectF(xr[0], yr[0], xr[1]-xr[0], yr[1]-yr[0])
                #print "   item real:", bounds
            else:
                if int(item.flags() & item.ItemHasNoContents) > 0:
                    continue
                    #print "   empty"
                else:
                    bounds = item.boundingRect()
                    #bounds = [[item.left(), item.top()], [item.right(), item.bottom()]]
                #print "   item:", bounds
            #bounds = QtCore.QRectF(bounds[0][0], bounds[1][0], bounds[0][1]-bounds[0][0], bounds[1][1]-bounds[1][0])
            bounds = self.mapFromItemToView(item, bounds).boundingRect()
            #print "    ", bounds
            
            
            if not any([useX, useY]):
                continue
            
            if useX != useY:  ##   !=  means  xor
                ang = item.transformAngle()
                if ang == 0 or ang == 180:
                    pass
                elif ang == 90 or ang == 270:
                    tmp = useX
                    useY = useX
                    useX = tmp
                else:
                    continue  ## need to check for item rotations and decide how best to apply this boundary. 
            
            
            if useY:
                if range[1] is not None:
                    range[1] = [min(bounds.top(), range[1][0]), max(bounds.bottom(), range[1][1])]
                    #bounds.setTop(min(bounds.top(), chb.top()))
                    #bounds.setBottom(max(bounds.bottom(), chb.bottom()))
                else:
                    range[1] = [bounds.top(), bounds.bottom()]
                    #bounds.setTop(chb.top())
                    #bounds.setBottom(chb.bottom())
            if useX:
                if range[0] is not None:
                    range[0] = [min(bounds.left(), range[0][0]), max(bounds.right(), range[0][1])]
                    #bounds.setLeft(min(bounds.left(), chb.left()))
                    #bounds.setRight(max(bounds.right(), chb.right()))
                else:
                    range[0] = [bounds.left(), bounds.right()]
                    #bounds.setLeft(chb.left())
                    #bounds.setRight(chb.right())
        
        tr = self.targetRange()
        if range[0] is None:
            range[0] = tr[0]
        if range[1] is None:
            range[1] = tr[1]
            
        bounds = QtCore.QRectF(range[0][0], range[1][0], range[0][1]-range[0][0], range[1][1]-range[1][0])
        return bounds
            
        

    def updateMatrix(self, changed=None):
        if changed is None:
            changed = [False, False]
        #print "udpateMatrix:"
        #print "  range:", self.range
        tr = self.targetRect()
        bounds = self.rect() #boundingRect()
        #print bounds
        
        ## set viewRect, given targetRect and possibly aspect ratio constraint
        if self.state['aspectLocked'] is False or bounds.height() == 0:
            self.state['viewRange'] = [self.state['targetRange'][0][:], self.state['targetRange'][1][:]]
        else:
            viewRatio = bounds.width() / bounds.height()
            targetRatio = self.state['aspectLocked'] * tr.width() / tr.height()
            if targetRatio > viewRatio:  
                ## target is wider than view
                dy = 0.5 * (tr.width() / (self.state['aspectLocked'] * 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:
                dx = 0.5 * (tr.height() * viewRatio * self.state['aspectLocked'] - 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()
        #print "  bounds:", bounds
        if vr.height() == 0 or vr.width() == 0:
            return
        scale = Point(bounds.width()/vr.width(), bounds.height()/vr.height())
        if not self.state['yInverted']:
            scale = scale * Point(1, -1)
        m = QtGui.QTransform()
        
        ## First center the viewport at 0
        #self.childGroup.resetTransform()
        #self.resetTransform()
        #center = self.transform().inverted()[0].map(bounds.center())
        center = bounds.center()
        #print "  transform to center:", center
        #if self.state['yInverted']:
            #m.translate(center.x(), -center.y())
            #print "  inverted; translate", center.x(), center.y()
        #else:
        m.translate(center.x(), center.y())
            #print "  not inverted; translate", center.x(), -center.y()