Merge branch 'develop' into image-testing

Conflicts:
	pyqtgraph/tests/__init__.py
This commit is contained in:
Luke Campagnola 2016-03-17 22:29:06 -07:00
commit c65ca6d243
13 changed files with 808 additions and 241 deletions

45
examples/InfiniteLine.py Normal file
View File

@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
"""
This example demonstrates some of the plotting items available in pyqtgraph.
"""
import initExample ## Add path to library (just for examples; you do not need this)
from pyqtgraph.Qt import QtGui, QtCore
import numpy as np
import pyqtgraph as pg
app = QtGui.QApplication([])
win = pg.GraphicsWindow(title="Plotting items examples")
win.resize(1000,600)
# Enable antialiasing for prettier plots
pg.setConfigOptions(antialias=True)
# Create a plot with some random data
p1 = win.addPlot(title="Plot Items example", y=np.random.normal(size=100, scale=10), pen=0.5)
p1.setYRange(-40, 40)
# Add three infinite lines with labels
inf1 = pg.InfiniteLine(movable=True, angle=90, label='x={value:0.2f}',
labelOpts={'position':0.1, 'color': (200,200,100), 'fill': (200,200,200,50), 'movable': True})
inf2 = pg.InfiniteLine(movable=True, angle=0, pen=(0, 0, 200), bounds = [-20, 20], hoverPen=(0,200,0), label='y={value:0.2f}mm',
labelOpts={'color': (200,0,0), 'movable': True, 'fill': (0, 0, 200, 100)})
inf3 = pg.InfiniteLine(movable=True, angle=45, pen='g', label='diagonal',
labelOpts={'rotateAxis': [1, 0], 'fill': (0, 200, 0, 100), 'movable': True})
inf1.setPos([2,2])
p1.addItem(inf1)
p1.addItem(inf2)
p1.addItem(inf3)
# Add a linear region with a label
lr = pg.LinearRegionItem(values=[70, 80])
p1.addItem(lr)
label = pg.InfLineLabel(lr.lines[1], "region 1", position=0.95, rotateAxis=(1,0), anchor=(1, 1))
## Start Qt event loop unless running in interactive mode or using pyside.
if __name__ == '__main__':
import sys
if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'):
QtGui.QApplication.instance().exec_()

38
examples/Symbols.py Executable file
View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""
This example shows all the scatter plot symbols available in pyqtgraph.
These symbols are used to mark point locations for scatter plots and some line
plots, similar to "markers" in matplotlib and vispy.
"""
import initExample ## Add path to library (just for examples; you do not need this)
from pyqtgraph.Qt import QtGui, QtCore
import pyqtgraph as pg
app = QtGui.QApplication([])
win = pg.GraphicsWindow(title="Scatter Plot Symbols")
win.resize(1000,600)
pg.setConfigOptions(antialias=True)
plot = win.addPlot(title="Plotting with symbols")
plot.addLegend()
plot.plot([0, 1, 2, 3, 4], pen=(0,0,200), symbolBrush=(0,0,200), symbolPen='w', symbol='o', symbolSize=14, name="symbol='o'")
plot.plot([1, 2, 3, 4, 5], pen=(0,128,0), symbolBrush=(0,128,0), symbolPen='w', symbol='t', symbolSize=14, name="symbol='t'")
plot.plot([2, 3, 4, 5, 6], pen=(19,234,201), symbolBrush=(19,234,201), symbolPen='w', symbol='t1', symbolSize=14, name="symbol='t1'")
plot.plot([3, 4, 5, 6, 7], pen=(195,46,212), symbolBrush=(195,46,212), symbolPen='w', symbol='t2', symbolSize=14, name="symbol='t2'")
plot.plot([4, 5, 6, 7, 8], pen=(250,194,5), symbolBrush=(250,194,5), symbolPen='w', symbol='t3', symbolSize=14, name="symbol='t3'")
plot.plot([5, 6, 7, 8, 9], pen=(54,55,55), symbolBrush=(55,55,55), symbolPen='w', symbol='s', symbolSize=14, name="symbol='s'")
plot.plot([6, 7, 8, 9, 10], pen=(0,114,189), symbolBrush=(0,114,189), symbolPen='w', symbol='p', symbolSize=14, name="symbol='p'")
plot.plot([7, 8, 9, 10, 11], pen=(217,83,25), symbolBrush=(217,83,25), symbolPen='w', symbol='h', symbolSize=14, name="symbol='h'")
plot.plot([8, 9, 10, 11, 12], pen=(237,177,32), symbolBrush=(237,177,32), symbolPen='w', symbol='star', symbolSize=14, name="symbol='star'")
plot.plot([9, 10, 11, 12, 13], pen=(126,47,142), symbolBrush=(126,47,142), symbolPen='w', symbol='+', symbolSize=14, name="symbol='+'")
plot.plot([10, 11, 12, 13, 14], pen=(119,172,48), symbolBrush=(119,172,48), symbolPen='w', symbol='d', symbolSize=14, name="symbol='d'")
plot.setXRange(-2, 4)
## Start Qt event loop unless running in interactive mode or using pyside.
if __name__ == '__main__':
import sys
if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'):
QtGui.QApplication.instance().exec_()

View File

@ -0,0 +1,52 @@
#!/usr/bin/python
import initExample ## Add path to library (just for examples; you do not need this)
from pyqtgraph.Qt import QtGui, QtCore
import numpy as np
import pyqtgraph as pg
from pyqtgraph.ptime import time
app = QtGui.QApplication([])
p = pg.plot()
p.setWindowTitle('pyqtgraph performance: InfiniteLine')
p.setRange(QtCore.QRectF(0, -10, 5000, 20))
p.setLabel('bottom', 'Index', units='B')
curve = p.plot()
# Add a large number of horizontal InfiniteLine to plot
for i in range(100):
line = pg.InfiniteLine(pos=np.random.randint(5000), movable=True)
p.addItem(line)
data = np.random.normal(size=(50, 5000))
ptr = 0
lastTime = time()
fps = None
def update():
global curve, data, ptr, p, lastTime, fps
curve.setData(data[ptr % 10])
ptr += 1
now = time()
dt = now - lastTime
lastTime = now
if fps is None:
fps = 1.0/dt
else:
s = np.clip(dt*3., 0, 1)
fps = fps * (1-s) + (1.0/dt) * s
p.setTitle('%0.2f fps' % fps)
app.processEvents() # force complete redraw for every plot
timer = QtCore.QTimer()
timer.timeout.connect(update)
timer.start(0)
# Start Qt event loop unless running in interactive mode.
if __name__ == '__main__':
import sys
if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'):
QtGui.QApplication.instance().exec_()

View File

@ -23,7 +23,7 @@ plot.setWindowTitle('pyqtgraph example: text')
curve = plot.plot(x,y) ## add a single curve curve = plot.plot(x,y) ## add a single curve
## Create text object, use HTML tags to specify color/size ## Create text object, use HTML tags to specify color/size
text = pg.TextItem(html='<div style="text-align: center"><span style="color: #FFF;">This is the</span><br><span style="color: #FF0; font-size: 16pt;">PEAK</span></div>', anchor=(-0.3,1.3), border='w', fill=(0, 0, 255, 100)) text = pg.TextItem(html='<div style="text-align: center"><span style="color: #FFF;">This is the</span><br><span style="color: #FF0; font-size: 16pt;">PEAK</span></div>', anchor=(-0.3,0.5), angle=45, border='w', fill=(0, 0, 255, 100))
plot.addItem(text) plot.addItem(text)
text.setPos(0, y.max()) text.setPos(0, y.max())

View File

@ -22,6 +22,7 @@ examples = OrderedDict([
('Console', 'ConsoleWidget.py'), ('Console', 'ConsoleWidget.py'),
('Histograms', 'histogram.py'), ('Histograms', 'histogram.py'),
('Beeswarm plot', 'beeswarm.py'), ('Beeswarm plot', 'beeswarm.py'),
('Symbols', 'Symbols.py'),
('Auto-range', 'PlotAutoRange.py'), ('Auto-range', 'PlotAutoRange.py'),
('Remote Plotting', 'RemoteSpeedTest.py'), ('Remote Plotting', 'RemoteSpeedTest.py'),
('Scrolling plots', 'scrollingPlots.py'), ('Scrolling plots', 'scrollingPlots.py'),

View File

@ -98,6 +98,7 @@ class GraphicsScene(QtGui.QGraphicsScene):
self.lastDrag = None self.lastDrag = None
self.hoverItems = weakref.WeakKeyDictionary() self.hoverItems = weakref.WeakKeyDictionary()
self.lastHoverEvent = None self.lastHoverEvent = None
self.minDragTime = 0.5 # drags shorter than 0.5 sec are interpreted as clicks
self.contextMenu = [QtGui.QAction("Export...", self)] self.contextMenu = [QtGui.QAction("Export...", self)]
self.contextMenu[0].triggered.connect(self.showExportDialog) self.contextMenu[0].triggered.connect(self.showExportDialog)
@ -173,7 +174,7 @@ class GraphicsScene(QtGui.QGraphicsScene):
if int(btn) not in self.dragButtons: ## see if we've dragged far enough yet if int(btn) not in self.dragButtons: ## see if we've dragged far enough yet
cev = [e for e in self.clickEvents if int(e.button()) == int(btn)][0] cev = [e for e in self.clickEvents if int(e.button()) == int(btn)][0]
dist = Point(ev.screenPos() - cev.screenPos()) dist = Point(ev.screenPos() - cev.screenPos())
if dist.length() < self._moveDistance and now - cev.time() < 0.5: if dist.length() < self._moveDistance and now - cev.time() < self.minDragTime:
continue continue
init = init or (len(self.dragButtons) == 0) ## If this is the first button to be dragged, then init=True init = init or (len(self.dragButtons) == 0) ## If this is the first button to be dragged, then init=True
self.dragButtons.append(int(btn)) self.dragButtons.append(int(btn))
@ -186,10 +187,8 @@ class GraphicsScene(QtGui.QGraphicsScene):
def leaveEvent(self, ev): ## inform items that mouse is gone def leaveEvent(self, ev): ## inform items that mouse is gone
if len(self.dragButtons) == 0: if len(self.dragButtons) == 0:
self.sendHoverEvents(ev, exitOnly=True) self.sendHoverEvents(ev, exitOnly=True)
def mouseReleaseEvent(self, ev): def mouseReleaseEvent(self, ev):
#print 'sceneRelease'
if self.mouseGrabberItem() is None: if self.mouseGrabberItem() is None:
if ev.button() in self.dragButtons: if ev.button() in self.dragButtons:
if self.sendDragEvent(ev, final=True): if self.sendDragEvent(ev, final=True):

View File

@ -9,7 +9,7 @@ This module exists to smooth out some of the differences between PySide and PyQt
""" """
import sys, re import sys, re, time
from .python2_3 import asUnicode from .python2_3 import asUnicode
@ -45,6 +45,15 @@ if QT_LIB == PYSIDE:
from PySide import QtGui, QtCore, QtOpenGL, QtSvg from PySide import QtGui, QtCore, QtOpenGL, QtSvg
try: try:
from PySide import QtTest from PySide import QtTest
if not hasattr(QtTest.QTest, 'qWait'):
@staticmethod
def qWait(msec):
start = time.time()
QtGui.QApplication.processEvents()
while time.time() < start + msec * 0.001:
QtGui.QApplication.processEvents()
QtTest.QTest.qWait = qWait
except ImportError: except ImportError:
pass pass
import PySide import PySide

View File

@ -1,19 +1,23 @@
from ..Qt import QtGui, QtCore from ..Qt import QtGui, QtCore
from ..Point import Point from ..Point import Point
from .GraphicsObject import GraphicsObject from .GraphicsObject import GraphicsObject
from .TextItem import TextItem
from .ViewBox import ViewBox
from .. import functions as fn from .. import functions as fn
import numpy as np import numpy as np
import weakref import weakref
__all__ = ['InfiniteLine'] __all__ = ['InfiniteLine', 'InfLineLabel']
class InfiniteLine(GraphicsObject): class InfiniteLine(GraphicsObject):
""" """
**Bases:** :class:`GraphicsObject <pyqtgraph.GraphicsObject>` **Bases:** :class:`GraphicsObject <pyqtgraph.GraphicsObject>`
Displays a line of infinite length. Displays a line of infinite length.
This line may be dragged to indicate a position in data coordinates. This line may be dragged to indicate a position in data coordinates.
=============================== =================================================== =============================== ===================================================
**Signals:** **Signals:**
sigDragged(self) sigDragged(self)
@ -21,12 +25,13 @@ class InfiniteLine(GraphicsObject):
sigPositionChanged(self) sigPositionChanged(self)
=============================== =================================================== =============================== ===================================================
""" """
sigDragged = QtCore.Signal(object) sigDragged = QtCore.Signal(object)
sigPositionChangeFinished = QtCore.Signal(object) sigPositionChangeFinished = QtCore.Signal(object)
sigPositionChanged = QtCore.Signal(object) sigPositionChanged = QtCore.Signal(object)
def __init__(self, pos=None, angle=90, pen=None, movable=False, bounds=None): def __init__(self, pos=None, angle=90, pen=None, movable=False, bounds=None,
hoverPen=None, label=None, labelOpts=None, name=None):
""" """
=============== ================================================================== =============== ==================================================================
**Arguments:** **Arguments:**
@ -37,13 +42,26 @@ class InfiniteLine(GraphicsObject):
for :func:`mkPen <pyqtgraph.mkPen>`. Default pen is transparent for :func:`mkPen <pyqtgraph.mkPen>`. Default pen is transparent
yellow. yellow.
movable If True, the line can be dragged to a new position by the user. movable If True, the line can be dragged to a new position by the user.
hoverPen Pen to use when drawing line when hovering over it. Can be any
arguments that are valid for :func:`mkPen <pyqtgraph.mkPen>`.
Default pen is red.
bounds Optional [min, max] bounding values. Bounds are only valid if the bounds Optional [min, max] bounding values. Bounds are only valid if the
line is vertical or horizontal. 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`.
name Name of the item
=============== ================================================================== =============== ==================================================================
""" """
self._boundingRect = None
self._line = None
self._name = name
GraphicsObject.__init__(self) GraphicsObject.__init__(self)
if bounds is None: ## allowed value boundaries for orthogonal lines if bounds is None: ## allowed value boundaries for orthogonal lines
self.maxRange = [None, None] self.maxRange = [None, None]
else: else:
@ -53,63 +71,70 @@ class InfiniteLine(GraphicsObject):
self.mouseHovering = False self.mouseHovering = False
self.p = [0, 0] self.p = [0, 0]
self.setAngle(angle) self.setAngle(angle)
if pos is None: if pos is None:
pos = Point(0,0) pos = Point(0,0)
self.setPos(pos) self.setPos(pos)
if pen is None: if pen is None:
pen = (200, 200, 100) pen = (200, 200, 100)
self.setPen(pen) self.setPen(pen)
self.setHoverPen(color=(255,0,0), width=self.pen.width()) if hoverPen is None:
self.setHoverPen(color=(255,0,0), width=self.pen.width())
else:
self.setHoverPen(hoverPen)
self.currentPen = self.pen self.currentPen = self.pen
if label is not None:
labelOpts = {} if labelOpts is None else labelOpts
self.label = InfLineLabel(self, text=label, **labelOpts)
def setMovable(self, m): def setMovable(self, m):
"""Set whether the line is movable by the user.""" """Set whether the line is movable by the user."""
self.movable = m self.movable = m
self.setAcceptHoverEvents(m) self.setAcceptHoverEvents(m)
def setBounds(self, bounds): def setBounds(self, bounds):
"""Set the (minimum, maximum) allowable values when dragging.""" """Set the (minimum, maximum) allowable values when dragging."""
self.maxRange = bounds self.maxRange = bounds
self.setValue(self.value()) self.setValue(self.value())
def setPen(self, *args, **kwargs): def setPen(self, *args, **kwargs):
"""Set the pen for drawing the line. Allowable arguments are any that are valid """Set the pen for drawing the line. Allowable arguments are any that are valid
for :func:`mkPen <pyqtgraph.mkPen>`.""" for :func:`mkPen <pyqtgraph.mkPen>`."""
self.pen = fn.mkPen(*args, **kwargs) self.pen = fn.mkPen(*args, **kwargs)
if not self.mouseHovering: if not self.mouseHovering:
self.currentPen = self.pen self.currentPen = self.pen
self.update() self.update()
def setHoverPen(self, *args, **kwargs): def setHoverPen(self, *args, **kwargs):
"""Set the pen for drawing the line while the mouse hovers over it. """Set the pen for drawing the line while the mouse hovers over it.
Allowable arguments are any that are valid Allowable arguments are any that are valid
for :func:`mkPen <pyqtgraph.mkPen>`. for :func:`mkPen <pyqtgraph.mkPen>`.
If the line is not movable, then hovering is also disabled. If the line is not movable, then hovering is also disabled.
Added in version 0.9.9.""" Added in version 0.9.9."""
self.hoverPen = fn.mkPen(*args, **kwargs) self.hoverPen = fn.mkPen(*args, **kwargs)
if self.mouseHovering: if self.mouseHovering:
self.currentPen = self.hoverPen self.currentPen = self.hoverPen
self.update() self.update()
def setAngle(self, angle): def setAngle(self, angle):
""" """
Takes angle argument in degrees. Takes angle argument in degrees.
0 is horizontal; 90 is vertical. 0 is horizontal; 90 is vertical.
Note that the use of value() and setValue() changes if the line is Note that the use of value() and setValue() changes if the line is
not vertical or horizontal. not vertical or horizontal.
""" """
self.angle = ((angle+45) % 180) - 45 ## -45 <= angle < 135 self.angle = ((angle+45) % 180) - 45 ## -45 <= angle < 135
self.resetTransform() self.resetTransform()
self.rotate(self.angle) self.rotate(self.angle)
self.update() self.update()
def setPos(self, pos): def setPos(self, pos):
if type(pos) in [list, tuple]: if type(pos) in [list, tuple]:
newPos = pos newPos = pos
elif isinstance(pos, QtCore.QPointF): elif isinstance(pos, QtCore.QPointF):
@ -121,10 +146,10 @@ class InfiniteLine(GraphicsObject):
newPos = [0, pos] newPos = [0, pos]
else: else:
raise Exception("Must specify 2D coordinate for non-orthogonal lines.") raise Exception("Must specify 2D coordinate for non-orthogonal lines.")
## check bounds (only works for orthogonal lines) ## check bounds (only works for orthogonal lines)
if self.angle == 90: if self.angle == 90:
if self.maxRange[0] is not None: if self.maxRange[0] is not None:
newPos[0] = max(newPos[0], self.maxRange[0]) newPos[0] = max(newPos[0], self.maxRange[0])
if self.maxRange[1] is not None: if self.maxRange[1] is not None:
newPos[0] = min(newPos[0], self.maxRange[1]) newPos[0] = min(newPos[0], self.maxRange[1])
@ -133,24 +158,24 @@ class InfiniteLine(GraphicsObject):
newPos[1] = max(newPos[1], self.maxRange[0]) newPos[1] = max(newPos[1], self.maxRange[0])
if self.maxRange[1] is not None: if self.maxRange[1] is not None:
newPos[1] = min(newPos[1], self.maxRange[1]) newPos[1] = min(newPos[1], self.maxRange[1])
if self.p != newPos: if self.p != newPos:
self.p = newPos self.p = newPos
self._invalidateCache()
GraphicsObject.setPos(self, Point(self.p)) GraphicsObject.setPos(self, Point(self.p))
self.update()
self.sigPositionChanged.emit(self) self.sigPositionChanged.emit(self)
def getXPos(self): def getXPos(self):
return self.p[0] return self.p[0]
def getYPos(self): def getYPos(self):
return self.p[1] return self.p[1]
def getPos(self): def getPos(self):
return self.p return self.p
def value(self): def value(self):
"""Return the value of the line. Will be a single number for horizontal and """Return the value of the line. Will be a single number for horizontal and
vertical lines, and a list of [x,y] values for diagonal lines.""" vertical lines, and a list of [x,y] values for diagonal lines."""
if self.angle%180 == 0: if self.angle%180 == 0:
return self.getYPos() return self.getYPos()
@ -158,10 +183,10 @@ class InfiniteLine(GraphicsObject):
return self.getXPos() return self.getXPos()
else: else:
return self.getPos() return self.getPos()
def setValue(self, v): def setValue(self, v):
"""Set the position of the line. If line is horizontal or vertical, v can be """Set the position of the line. If line is horizontal or vertical, v can be
a single value. Otherwise, a 2D coordinate must be specified (list, tuple and a single value. Otherwise, a 2D coordinate must be specified (list, tuple and
QPointF are all acceptable).""" QPointF are all acceptable)."""
self.setPos(v) self.setPos(v)
@ -174,25 +199,35 @@ class InfiniteLine(GraphicsObject):
#else: #else:
#print "ignore", change #print "ignore", change
#return GraphicsObject.itemChange(self, change, val) #return GraphicsObject.itemChange(self, change, val)
def _invalidateCache(self):
self._line = None
self._boundingRect = None
def boundingRect(self): def boundingRect(self):
#br = UIGraphicsItem.boundingRect(self) if self._boundingRect is None:
br = self.viewRect() #br = UIGraphicsItem.boundingRect(self)
## add a 4-pixel radius around the line for mouse interaction. br = self.viewRect()
if br is None:
px = self.pixelLength(direction=Point(1,0), ortho=True) ## get pixel length orthogonal to the line return QtCore.QRectF()
if px is None:
px = 0 ## add a 4-pixel radius around the line for mouse interaction.
w = (max(4, self.pen.width()/2, self.hoverPen.width()/2)+1) * px px = self.pixelLength(direction=Point(1,0), ortho=True) ## get pixel length orthogonal to the line
br.setBottom(-w) if px is None:
br.setTop(w) px = 0
return br.normalized() 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)
return self._boundingRect
def paint(self, p, *args): def paint(self, p, *args):
br = self.boundingRect()
p.setPen(self.currentPen) p.setPen(self.currentPen)
p.drawLine(Point(br.right(), 0), Point(br.left(), 0)) p.drawLine(self._line)
def dataBounds(self, axis, frac=1.0, orthoRange=None): def dataBounds(self, axis, frac=1.0, orthoRange=None):
if axis == 0: if axis == 0:
return None ## x axis should never be auto-scaled return None ## x axis should never be auto-scaled
@ -206,16 +241,16 @@ class InfiniteLine(GraphicsObject):
self.cursorOffset = self.pos() - self.mapToParent(ev.buttonDownPos()) self.cursorOffset = self.pos() - self.mapToParent(ev.buttonDownPos())
self.startPosition = self.pos() self.startPosition = self.pos()
ev.accept() ev.accept()
if not self.moving: if not self.moving:
return return
self.setPos(self.cursorOffset + self.mapToParent(ev.pos())) self.setPos(self.cursorOffset + self.mapToParent(ev.pos()))
self.sigDragged.emit(self) self.sigDragged.emit(self)
if ev.isFinish(): if ev.isFinish():
self.moving = False self.moving = False
self.sigPositionChangeFinished.emit(self) self.sigPositionChangeFinished.emit(self)
def mouseClickEvent(self, ev): def mouseClickEvent(self, ev):
if self.moving and ev.button() == QtCore.Qt.RightButton: if self.moving and ev.button() == QtCore.Qt.RightButton:
ev.accept() ev.accept()
@ -240,3 +275,195 @@ class InfiniteLine(GraphicsObject):
else: else:
self.currentPen = self.pen self.currentPen = self.pen
self.update() self.update()
def viewTransformChanged(self):
"""
Called whenever the transformation matrix of the view has changed.
(eg, the view range has changed or the view was resized)
"""
self._invalidateCache()
def setName(self, name):
self._name = name
def name(self):
return self._name
class InfLineLabel(TextItem):
"""
A TextItem that attaches itself to an InfiniteLine.
This class extends TextItem with the following features:
* Automatically positions adjacent to the line at a fixed position along
the line and within the view box.
* Automatically reformats text when the line value has changed.
* Can optionally be dragged to change its location along the line.
* Optionally aligns to its parent line.
=============== ==================================================================
**Arguments:**
line The InfiniteLine to which this label will be attached.
text String to display in the label. May contain a {value} formatting
string to display the current value of the line.
movable Bool; if True, then the label can be dragged along the line.
position Relative position (0.0-1.0) within the view to position the label
along the line.
anchors List of (x,y) pairs giving the text anchor positions that should
be used when the line is moved to one side of the view or the
other. This allows text to switch to the opposite side of the line
as it approaches the edge of the view. These are automatically
selected for some common cases, but may be specified if the
default values give unexpected results.
=============== ==================================================================
All extra keyword arguments are passed to TextItem. A particularly useful
option here is to use `rotateAxis=(1, 0)`, which will cause the text to
be automatically rotated parallel to the line.
"""
def __init__(self, line, text="", movable=False, position=0.5, anchors=None, **kwds):
self.line = line
self.movable = movable
self.orthoPos = position # text will always be placed on the line at a position relative to view bounds
self.format = text
self.line.sigPositionChanged.connect(self.valueChanged)
self._endpoints = (None, None)
if anchors is None:
# automatically pick sensible anchors
rax = kwds.get('rotateAxis', None)
if rax is not None:
if tuple(rax) == (1,0):
anchors = [(0.5, 0), (0.5, 1)]
else:
anchors = [(0, 0.5), (1, 0.5)]
else:
if line.angle % 180 == 0:
anchors = [(0.5, 0), (0.5, 1)]
else:
anchors = [(0, 0.5), (1, 0.5)]
self.anchors = anchors
TextItem.__init__(self, **kwds)
self.setParentItem(line)
self.valueChanged()
def valueChanged(self):
if not self.isVisible():
return
value = self.line.value()
self.setText(self.format.format(value=value))
self.updatePosition()
def getEndpoints(self):
# calculate points where line intersects view box
# (in line coordinates)
if self._endpoints[0] is None:
lr = self.line.boundingRect()
pt1 = Point(lr.left(), 0)
pt2 = Point(lr.right(), 0)
if self.line.angle % 90 != 0:
# more expensive to find text position for oblique lines.
view = self.getViewBox()
if not self.isVisible() or not isinstance(view, ViewBox):
# not in a viewbox, skip update
return (None, None)
p = QtGui.QPainterPath()
p.moveTo(pt1)
p.lineTo(pt2)
p = self.line.itemTransform(view)[0].map(p)
vr = QtGui.QPainterPath()
vr.addRect(view.boundingRect())
paths = vr.intersected(p).toSubpathPolygons(QtGui.QTransform())
if len(paths) > 0:
l = list(paths[0])
pt1 = self.line.mapFromItem(view, l[0])
pt2 = self.line.mapFromItem(view, l[1])
self._endpoints = (pt1, pt2)
return self._endpoints
def updatePosition(self):
# update text position to relative view location along line
self._endpoints = (None, None)
pt1, pt2 = self.getEndpoints()
if pt1 is None:
return
pt = pt2 * self.orthoPos + pt1 * (1-self.orthoPos)
self.setPos(pt)
# update anchor to keep text visible as it nears the view box edge
vr = self.line.viewRect()
if vr is not None:
self.setAnchor(self.anchors[0 if vr.center().y() < 0 else 1])
def setVisible(self, v):
TextItem.setVisible(self, v)
if v:
self.updateText()
self.updatePosition()
def setMovable(self, m):
"""Set whether this label is movable by dragging along the line.
"""
self.movable = m
self.setAcceptHoverEvents(m)
def setPosition(self, p):
"""Set the relative position (0.0-1.0) of this label within the view box
and along the line.
For horizontal (angle=0) and vertical (angle=90) lines, a value of 0.0
places the text at the bottom or left of the view, respectively.
"""
self.orthoPos = p
self.updatePosition()
def setFormat(self, text):
"""Set the text format string for this label.
May optionally contain "{value}" to include the lines current value
(the text will be reformatted whenever the line is moved).
"""
self.format = format
self.valueChanged()
def mouseDragEvent(self, ev):
if self.movable and ev.button() == QtCore.Qt.LeftButton:
if ev.isStart():
self._moving = True
self._cursorOffset = self._posToRel(ev.buttonDownPos())
self._startPosition = self.orthoPos
ev.accept()
if not self._moving:
return
rel = self._posToRel(ev.pos())
self.orthoPos = np.clip(self._startPosition + rel - self._cursorOffset, 0, 1)
self.updatePosition()
if ev.isFinish():
self._moving = False
def mouseClickEvent(self, ev):
if self.moving and ev.button() == QtCore.Qt.RightButton:
ev.accept()
self.orthoPos = self._startPosition
self.moving = False
def hoverEvent(self, ev):
if not ev.isExit() and self.movable:
ev.acceptDrags(QtCore.Qt.LeftButton)
def viewTransformChanged(self):
self.updatePosition()
TextItem.viewTransformChanged(self)
def _posToRel(self, pos):
# convert local position to relative position along line between view bounds
pt1, pt2 = self.getEndpoints()
if pt1 is None:
return 0
view = self.getViewBox()
pos = self.mapToParent(pos)
return (pos.x() - pt1.x()) / (pt2.x()-pt1.x())

View File

@ -19,17 +19,28 @@ __all__ = ['ScatterPlotItem', 'SpotItem']
## Build all symbol paths ## Build all symbol paths
Symbols = OrderedDict([(name, QtGui.QPainterPath()) for name in ['o', 's', 't', 'd', '+', 'x']]) Symbols = OrderedDict([(name, QtGui.QPainterPath()) for name in ['o', 's', 't', 't1', 't2', 't3','d', '+', 'x', 'p', 'h', 'star']])
Symbols['o'].addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1)) Symbols['o'].addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1))
Symbols['s'].addRect(QtCore.QRectF(-0.5, -0.5, 1, 1)) Symbols['s'].addRect(QtCore.QRectF(-0.5, -0.5, 1, 1))
coords = { coords = {
't': [(-0.5, -0.5), (0, 0.5), (0.5, -0.5)], 't': [(-0.5, -0.5), (0, 0.5), (0.5, -0.5)],
't1': [(-0.5, 0.5), (0, -0.5), (0.5, 0.5)],
't2': [(-0.5, -0.5), (-0.5, 0.5), (0.5, 0)],
't3': [(0.5, 0.5), (0.5, -0.5), (-0.5, 0)],
'd': [(0., -0.5), (-0.4, 0.), (0, 0.5), (0.4, 0)], 'd': [(0., -0.5), (-0.4, 0.), (0, 0.5), (0.4, 0)],
'+': [ '+': [
(-0.5, -0.05), (-0.5, 0.05), (-0.05, 0.05), (-0.05, 0.5), (-0.5, -0.05), (-0.5, 0.05), (-0.05, 0.05), (-0.05, 0.5),
(0.05, 0.5), (0.05, 0.05), (0.5, 0.05), (0.5, -0.05), (0.05, 0.5), (0.05, 0.05), (0.5, 0.05), (0.5, -0.05),
(0.05, -0.05), (0.05, -0.5), (-0.05, -0.5), (-0.05, -0.05) (0.05, -0.05), (0.05, -0.5), (-0.05, -0.5), (-0.05, -0.05)
], ],
'p': [(0, -0.5), (-0.4755, -0.1545), (-0.2939, 0.4045),
(0.2939, 0.4045), (0.4755, -0.1545)],
'h': [(0.433, 0.25), (0., 0.5), (-0.433, 0.25), (-0.433, -0.25),
(0, -0.5), (0.433, -0.25)],
'star': [(0, -0.5), (-0.1123, -0.1545), (-0.4755, -0.1545),
(-0.1816, 0.059), (-0.2939, 0.4045), (0, 0.1910),
(0.2939, 0.4045), (0.1816, 0.059), (0.4755, -0.1545),
(0.1123, -0.1545)]
} }
for k, c in coords.items(): for k, c in coords.items():
Symbols[k].moveTo(*c[0]) Symbols[k].moveTo(*c[0])
@ -40,7 +51,7 @@ tr = QtGui.QTransform()
tr.rotate(45) tr.rotate(45)
Symbols['x'] = tr.map(Symbols['+']) Symbols['x'] = tr.map(Symbols['+'])
def drawSymbol(painter, symbol, size, pen, brush): def drawSymbol(painter, symbol, size, pen, brush):
if symbol is None: if symbol is None:
return return
@ -53,13 +64,13 @@ def drawSymbol(painter, symbol, size, pen, brush):
symbol = list(Symbols.values())[symbol % len(Symbols)] symbol = list(Symbols.values())[symbol % len(Symbols)]
painter.drawPath(symbol) painter.drawPath(symbol)
def renderSymbol(symbol, size, pen, brush, device=None): def renderSymbol(symbol, size, pen, brush, device=None):
""" """
Render a symbol specification to QImage. Render a symbol specification to QImage.
Symbol may be either a QPainterPath or one of the keys in the Symbols dict. Symbol may be either a QPainterPath or one of the keys in the Symbols dict.
If *device* is None, a new QPixmap will be returned. Otherwise, If *device* is None, a new QPixmap will be returned. Otherwise,
the symbol will be rendered into the device specified (See QPainter documentation the symbol will be rendered into the device specified (See QPainter documentation
for more information). for more information).
""" """
## Render a spot with the given parameters to a pixmap ## Render a spot with the given parameters to a pixmap
@ -80,33 +91,33 @@ def makeSymbolPixmap(size, pen, brush, symbol):
## deprecated ## deprecated
img = renderSymbol(symbol, size, pen, brush) img = renderSymbol(symbol, size, pen, brush)
return QtGui.QPixmap(img) return QtGui.QPixmap(img)
class SymbolAtlas(object): class SymbolAtlas(object):
""" """
Used to efficiently construct a single QPixmap containing all rendered symbols Used to efficiently construct a single QPixmap containing all rendered symbols
for a ScatterPlotItem. This is required for fragment rendering. for a ScatterPlotItem. This is required for fragment rendering.
Use example: Use example:
atlas = SymbolAtlas() atlas = SymbolAtlas()
sc1 = atlas.getSymbolCoords('o', 5, QPen(..), QBrush(..)) sc1 = atlas.getSymbolCoords('o', 5, QPen(..), QBrush(..))
sc2 = atlas.getSymbolCoords('t', 10, QPen(..), QBrush(..)) sc2 = atlas.getSymbolCoords('t', 10, QPen(..), QBrush(..))
pm = atlas.getAtlas() pm = atlas.getAtlas()
""" """
def __init__(self): def __init__(self):
# symbol key : QRect(...) coordinates where symbol can be found in atlas. # symbol key : QRect(...) coordinates where symbol can be found in atlas.
# note that the coordinate list will always be the same list object as # note that the coordinate list will always be the same list object as
# long as the symbol is in the atlas, but the coordinates may # long as the symbol is in the atlas, but the coordinates may
# change if the atlas is rebuilt. # change if the atlas is rebuilt.
# weak value; if all external refs to this list disappear, # weak value; if all external refs to this list disappear,
# the symbol will be forgotten. # the symbol will be forgotten.
self.symbolMap = weakref.WeakValueDictionary() self.symbolMap = weakref.WeakValueDictionary()
self.atlasData = None # numpy array of atlas image self.atlasData = None # numpy array of atlas image
self.atlas = None # atlas as QPixmap self.atlas = None # atlas as QPixmap
self.atlasValid = False self.atlasValid = False
self.max_width=0 self.max_width=0
def getSymbolCoords(self, opts): def getSymbolCoords(self, opts):
""" """
Given a list of spot records, return an object representing the coordinates of that symbol within the atlas Given a list of spot records, return an object representing the coordinates of that symbol within the atlas
@ -131,7 +142,7 @@ class SymbolAtlas(object):
keyi = key keyi = key
sourceRecti = newRectSrc sourceRecti = newRectSrc
return sourceRect return sourceRect
def buildAtlas(self): def buildAtlas(self):
# get rendered array for all symbols, keep track of avg/max width # get rendered array for all symbols, keep track of avg/max width
rendered = {} rendered = {}
@ -150,7 +161,7 @@ class SymbolAtlas(object):
w = arr.shape[0] w = arr.shape[0]
avgWidth += w avgWidth += w
maxWidth = max(maxWidth, w) maxWidth = max(maxWidth, w)
nSymbols = len(rendered) nSymbols = len(rendered)
if nSymbols > 0: if nSymbols > 0:
avgWidth /= nSymbols avgWidth /= nSymbols
@ -158,10 +169,10 @@ class SymbolAtlas(object):
else: else:
avgWidth = 0 avgWidth = 0
width = 0 width = 0
# sort symbols by height # sort symbols by height
symbols = sorted(rendered.keys(), key=lambda x: rendered[x].shape[1], reverse=True) symbols = sorted(rendered.keys(), key=lambda x: rendered[x].shape[1], reverse=True)
self.atlasRows = [] self.atlasRows = []
x = width x = width
@ -187,7 +198,7 @@ class SymbolAtlas(object):
self.atlas = None self.atlas = None
self.atlasValid = True self.atlasValid = True
self.max_width = maxWidth self.max_width = maxWidth
def getAtlas(self): def getAtlas(self):
if not self.atlasValid: if not self.atlasValid:
self.buildAtlas() self.buildAtlas()
@ -197,27 +208,27 @@ class SymbolAtlas(object):
img = fn.makeQImage(self.atlasData, copy=False, transpose=False) img = fn.makeQImage(self.atlasData, copy=False, transpose=False)
self.atlas = QtGui.QPixmap(img) self.atlas = QtGui.QPixmap(img)
return self.atlas return self.atlas
class ScatterPlotItem(GraphicsObject): class ScatterPlotItem(GraphicsObject):
""" """
Displays a set of x/y points. Instances of this class are created Displays a set of x/y points. Instances of this class are created
automatically as part of PlotDataItem; these rarely need to be instantiated automatically as part of PlotDataItem; these rarely need to be instantiated
directly. directly.
The size, shape, pen, and fill brush may be set for each point individually The size, shape, pen, and fill brush may be set for each point individually
or for all points. or for all points.
======================== =============================================== ======================== ===============================================
**Signals:** **Signals:**
sigPlotChanged(self) Emitted when the data being plotted has changed sigPlotChanged(self) Emitted when the data being plotted has changed
sigClicked(self, points) Emitted when the curve is clicked. Sends a list sigClicked(self, points) Emitted when the curve is clicked. Sends a list
of all the points under the mouse pointer. of all the points under the mouse pointer.
======================== =============================================== ======================== ===============================================
""" """
#sigPointClicked = QtCore.Signal(object, object) #sigPointClicked = QtCore.Signal(object, object)
sigClicked = QtCore.Signal(object, object) ## self, points sigClicked = QtCore.Signal(object, object) ## self, points
@ -228,17 +239,17 @@ class ScatterPlotItem(GraphicsObject):
""" """
profiler = debug.Profiler() profiler = debug.Profiler()
GraphicsObject.__init__(self) GraphicsObject.__init__(self)
self.picture = None # QPicture used for rendering when pxmode==False self.picture = None # QPicture used for rendering when pxmode==False
self.fragmentAtlas = SymbolAtlas() self.fragmentAtlas = SymbolAtlas()
self.data = np.empty(0, dtype=[('x', float), ('y', float), ('size', float), ('symbol', object), ('pen', object), ('brush', object), ('data', object), ('item', object), ('sourceRect', object), ('targetRect', object), ('width', float)]) self.data = np.empty(0, dtype=[('x', float), ('y', float), ('size', float), ('symbol', object), ('pen', object), ('brush', object), ('data', object), ('item', object), ('sourceRect', object), ('targetRect', object), ('width', float)])
self.bounds = [None, None] ## caches data bounds self.bounds = [None, None] ## caches data bounds
self._maxSpotWidth = 0 ## maximum size of the scale-variant portion of all spots self._maxSpotWidth = 0 ## maximum size of the scale-variant portion of all spots
self._maxSpotPxWidth = 0 ## maximum size of the scale-invariant portion of all spots self._maxSpotPxWidth = 0 ## maximum size of the scale-invariant portion of all spots
self.opts = { self.opts = {
'pxMode': True, 'pxMode': True,
'useCache': True, ## If useCache is False, symbols are re-drawn on every paint. 'useCache': True, ## If useCache is False, symbols are re-drawn on every paint.
'antialias': getConfigOption('antialias'), 'antialias': getConfigOption('antialias'),
'name': None, 'name': None,
} }
@ -252,14 +263,14 @@ class ScatterPlotItem(GraphicsObject):
profiler('setData') profiler('setData')
#self.setCacheMode(self.DeviceCoordinateCache) #self.setCacheMode(self.DeviceCoordinateCache)
def setData(self, *args, **kargs): def setData(self, *args, **kargs):
""" """
**Ordered Arguments:** **Ordered Arguments:**
* If there is only one unnamed argument, it will be interpreted like the 'spots' argument. * If there is only one unnamed argument, it will be interpreted like the 'spots' argument.
* If there are two unnamed arguments, they will be interpreted as sequences of x and y values. * If there are two unnamed arguments, they will be interpreted as sequences of x and y values.
====================== =============================================================================================== ====================== ===============================================================================================
**Keyword Arguments:** **Keyword Arguments:**
*spots* Optional list of dicts. Each dict specifies parameters for a single spot: *spots* Optional list of dicts. Each dict specifies parameters for a single spot:
@ -285,8 +296,8 @@ class ScatterPlotItem(GraphicsObject):
it is in the item's local coordinate system. it is in the item's local coordinate system.
*data* a list of python objects used to uniquely identify each spot. *data* a list of python objects used to uniquely identify each spot.
*identical* *Deprecated*. This functionality is handled automatically now. *identical* *Deprecated*. This functionality is handled automatically now.
*antialias* Whether to draw symbols with antialiasing. Note that if pxMode is True, symbols are *antialias* Whether to draw symbols with antialiasing. Note that if pxMode is True, symbols are
always rendered with antialiasing (since the rendered symbols can be cached, this always rendered with antialiasing (since the rendered symbols can be cached, this
incurs very little performance cost) incurs very little performance cost)
*name* The name of this item. Names are used for automatically *name* The name of this item. Names are used for automatically
generating LegendItem entries and by some exporters. generating LegendItem entries and by some exporters.
@ -298,10 +309,10 @@ class ScatterPlotItem(GraphicsObject):
def addPoints(self, *args, **kargs): def addPoints(self, *args, **kargs):
""" """
Add new points to the scatter plot. Add new points to the scatter plot.
Arguments are the same as setData() Arguments are the same as setData()
""" """
## deal with non-keyword arguments ## deal with non-keyword arguments
if len(args) == 1: if len(args) == 1:
kargs['spots'] = args[0] kargs['spots'] = args[0]
@ -310,7 +321,7 @@ class ScatterPlotItem(GraphicsObject):
kargs['y'] = args[1] kargs['y'] = args[1]
elif len(args) > 2: elif len(args) > 2:
raise Exception('Only accepts up to two non-keyword arguments.') raise Exception('Only accepts up to two non-keyword arguments.')
## convert 'pos' argument to 'x' and 'y' ## convert 'pos' argument to 'x' and 'y'
if 'pos' in kargs: if 'pos' in kargs:
pos = kargs['pos'] pos = kargs['pos']
@ -329,7 +340,7 @@ class ScatterPlotItem(GraphicsObject):
y.append(p[1]) y.append(p[1])
kargs['x'] = x kargs['x'] = x
kargs['y'] = y kargs['y'] = y
## determine how many spots we have ## determine how many spots we have
if 'spots' in kargs: if 'spots' in kargs:
numPts = len(kargs['spots']) numPts = len(kargs['spots'])
@ -339,16 +350,16 @@ class ScatterPlotItem(GraphicsObject):
kargs['x'] = [] kargs['x'] = []
kargs['y'] = [] kargs['y'] = []
numPts = 0 numPts = 0
## Extend record array ## Extend record array
oldData = self.data oldData = self.data
self.data = np.empty(len(oldData)+numPts, dtype=self.data.dtype) self.data = np.empty(len(oldData)+numPts, dtype=self.data.dtype)
## note that np.empty initializes object fields to None and string fields to '' ## note that np.empty initializes object fields to None and string fields to ''
self.data[:len(oldData)] = oldData self.data[:len(oldData)] = oldData
#for i in range(len(oldData)): #for i in range(len(oldData)):
#oldData[i]['item']._data = self.data[i] ## Make sure items have proper reference to new array #oldData[i]['item']._data = self.data[i] ## Make sure items have proper reference to new array
newData = self.data[len(oldData):] newData = self.data[len(oldData):]
newData['size'] = -1 ## indicates to use default size newData['size'] = -1 ## indicates to use default size
@ -376,12 +387,12 @@ class ScatterPlotItem(GraphicsObject):
elif 'y' in kargs: elif 'y' in kargs:
newData['x'] = kargs['x'] newData['x'] = kargs['x']
newData['y'] = kargs['y'] newData['y'] = kargs['y']
if 'pxMode' in kargs: if 'pxMode' in kargs:
self.setPxMode(kargs['pxMode']) self.setPxMode(kargs['pxMode'])
if 'antialias' in kargs: if 'antialias' in kargs:
self.opts['antialias'] = kargs['antialias'] self.opts['antialias'] = kargs['antialias']
## Set any extra parameters provided in keyword arguments ## Set any extra parameters provided in keyword arguments
for k in ['pen', 'brush', 'symbol', 'size']: for k in ['pen', 'brush', 'symbol', 'size']:
if k in kargs: if k in kargs:
@ -397,32 +408,32 @@ class ScatterPlotItem(GraphicsObject):
self.invalidate() self.invalidate()
self.updateSpots(newData) self.updateSpots(newData)
self.sigPlotChanged.emit(self) self.sigPlotChanged.emit(self)
def invalidate(self): def invalidate(self):
## clear any cached drawing state ## clear any cached drawing state
self.picture = None self.picture = None
self.update() self.update()
def getData(self): def getData(self):
return self.data['x'], self.data['y'] return self.data['x'], self.data['y']
def setPoints(self, *args, **kargs): def setPoints(self, *args, **kargs):
##Deprecated; use setData ##Deprecated; use setData
return self.setData(*args, **kargs) return self.setData(*args, **kargs)
def implements(self, interface=None): def implements(self, interface=None):
ints = ['plotData'] ints = ['plotData']
if interface is None: if interface is None:
return ints return ints
return interface in ints return interface in ints
def name(self): def name(self):
return self.opts.get('name', None) return self.opts.get('name', None)
def setPen(self, *args, **kargs): def setPen(self, *args, **kargs):
"""Set the pen(s) used to draw the outline around each spot. """Set the pen(s) used to draw the outline around each spot.
If a list or array is provided, then the pen for each spot will be set separately. If a list or array is provided, then the pen for each spot will be set separately.
Otherwise, the arguments are passed to pg.mkPen and used as the default pen for Otherwise, the arguments are passed to pg.mkPen and used as the default pen for
all spots which do not have a pen explicitly set.""" all spots which do not have a pen explicitly set."""
update = kargs.pop('update', True) update = kargs.pop('update', True)
dataSet = kargs.pop('dataSet', self.data) dataSet = kargs.pop('dataSet', self.data)
@ -436,19 +447,19 @@ class ScatterPlotItem(GraphicsObject):
dataSet['pen'] = pens dataSet['pen'] = pens
else: else:
self.opts['pen'] = fn.mkPen(*args, **kargs) self.opts['pen'] = fn.mkPen(*args, **kargs)
dataSet['sourceRect'] = None dataSet['sourceRect'] = None
if update: if update:
self.updateSpots(dataSet) self.updateSpots(dataSet)
def setBrush(self, *args, **kargs): def setBrush(self, *args, **kargs):
"""Set the brush(es) used to fill the interior of each spot. """Set the brush(es) used to fill the interior of each spot.
If a list or array is provided, then the brush for each spot will be set separately. If a list or array is provided, then the brush for each spot will be set separately.
Otherwise, the arguments are passed to pg.mkBrush and used as the default brush for Otherwise, the arguments are passed to pg.mkBrush and used as the default brush for
all spots which do not have a brush explicitly set.""" all spots which do not have a brush explicitly set."""
update = kargs.pop('update', True) update = kargs.pop('update', True)
dataSet = kargs.pop('dataSet', self.data) dataSet = kargs.pop('dataSet', self.data)
if len(args) == 1 and (isinstance(args[0], np.ndarray) or isinstance(args[0], list)): if len(args) == 1 and (isinstance(args[0], np.ndarray) or isinstance(args[0], list)):
brushes = args[0] brushes = args[0]
if 'mask' in kargs and kargs['mask'] is not None: if 'mask' in kargs and kargs['mask'] is not None:
@ -459,19 +470,19 @@ class ScatterPlotItem(GraphicsObject):
else: else:
self.opts['brush'] = fn.mkBrush(*args, **kargs) self.opts['brush'] = fn.mkBrush(*args, **kargs)
#self._spotPixmap = None #self._spotPixmap = None
dataSet['sourceRect'] = None dataSet['sourceRect'] = None
if update: if update:
self.updateSpots(dataSet) self.updateSpots(dataSet)
def setSymbol(self, symbol, update=True, dataSet=None, mask=None): def setSymbol(self, symbol, update=True, dataSet=None, mask=None):
"""Set the symbol(s) used to draw each spot. """Set the symbol(s) used to draw each spot.
If a list or array is provided, then the symbol for each spot will be set separately. If a list or array is provided, then the symbol for each spot will be set separately.
Otherwise, the argument will be used as the default symbol for Otherwise, the argument will be used as the default symbol for
all spots which do not have a symbol explicitly set.""" all spots which do not have a symbol explicitly set."""
if dataSet is None: if dataSet is None:
dataSet = self.data dataSet = self.data
if isinstance(symbol, np.ndarray) or isinstance(symbol, list): if isinstance(symbol, np.ndarray) or isinstance(symbol, list):
symbols = symbol symbols = symbol
if mask is not None: if mask is not None:
@ -482,19 +493,19 @@ class ScatterPlotItem(GraphicsObject):
else: else:
self.opts['symbol'] = symbol self.opts['symbol'] = symbol
self._spotPixmap = None self._spotPixmap = None
dataSet['sourceRect'] = None dataSet['sourceRect'] = None
if update: if update:
self.updateSpots(dataSet) self.updateSpots(dataSet)
def setSize(self, size, update=True, dataSet=None, mask=None): def setSize(self, size, update=True, dataSet=None, mask=None):
"""Set the size(s) used to draw each spot. """Set the size(s) used to draw each spot.
If a list or array is provided, then the size for each spot will be set separately. If a list or array is provided, then the size for each spot will be set separately.
Otherwise, the argument will be used as the default size for Otherwise, the argument will be used as the default size for
all spots which do not have a size explicitly set.""" all spots which do not have a size explicitly set."""
if dataSet is None: if dataSet is None:
dataSet = self.data dataSet = self.data
if isinstance(size, np.ndarray) or isinstance(size, list): if isinstance(size, np.ndarray) or isinstance(size, list):
sizes = size sizes = size
if mask is not None: if mask is not None:
@ -505,21 +516,21 @@ class ScatterPlotItem(GraphicsObject):
else: else:
self.opts['size'] = size self.opts['size'] = size
self._spotPixmap = None self._spotPixmap = None
dataSet['sourceRect'] = None dataSet['sourceRect'] = None
if update: if update:
self.updateSpots(dataSet) self.updateSpots(dataSet)
def setPointData(self, data, dataSet=None, mask=None): def setPointData(self, data, dataSet=None, mask=None):
if dataSet is None: if dataSet is None:
dataSet = self.data dataSet = self.data
if isinstance(data, np.ndarray) or isinstance(data, list): if isinstance(data, np.ndarray) or isinstance(data, list):
if mask is not None: if mask is not None:
data = data[mask] data = data[mask]
if len(data) != len(dataSet): if len(data) != len(dataSet):
raise Exception("Length of meta data does not match number of points (%d != %d)" % (len(data), len(dataSet))) raise Exception("Length of meta data does not match number of points (%d != %d)" % (len(data), len(dataSet)))
## Bug: If data is a numpy record array, then items from that array must be copied to dataSet one at a time. ## Bug: If data is a numpy record array, then items from that array must be copied to dataSet one at a time.
## (otherwise they are converted to tuples and thus lose their field names. ## (otherwise they are converted to tuples and thus lose their field names.
if isinstance(data, np.ndarray) and (data.dtype.fields is not None)and len(data.dtype.fields) > 1: if isinstance(data, np.ndarray) and (data.dtype.fields is not None)and len(data.dtype.fields) > 1:
@ -527,14 +538,14 @@ class ScatterPlotItem(GraphicsObject):
dataSet['data'][i] = rec dataSet['data'][i] = rec
else: else:
dataSet['data'] = data dataSet['data'] = data
def setPxMode(self, mode): def setPxMode(self, mode):
if self.opts['pxMode'] == mode: if self.opts['pxMode'] == mode:
return return
self.opts['pxMode'] = mode self.opts['pxMode'] = mode
self.invalidate() self.invalidate()
def updateSpots(self, dataSet=None): def updateSpots(self, dataSet=None):
if dataSet is None: if dataSet is None:
dataSet = self.data dataSet = self.data
@ -547,9 +558,9 @@ class ScatterPlotItem(GraphicsObject):
opts = self.getSpotOpts(dataSet[mask]) opts = self.getSpotOpts(dataSet[mask])
sourceRect = self.fragmentAtlas.getSymbolCoords(opts) sourceRect = self.fragmentAtlas.getSymbolCoords(opts)
dataSet['sourceRect'][mask] = sourceRect dataSet['sourceRect'][mask] = sourceRect
self.fragmentAtlas.getAtlas() # generate atlas so source widths are available. self.fragmentAtlas.getAtlas() # generate atlas so source widths are available.
dataSet['width'] = np.array(list(imap(QtCore.QRectF.width, dataSet['sourceRect'])))/2 dataSet['width'] = np.array(list(imap(QtCore.QRectF.width, dataSet['sourceRect'])))/2
dataSet['targetRect'] = None dataSet['targetRect'] = None
self._maxSpotPxWidth = self.fragmentAtlas.max_width self._maxSpotPxWidth = self.fragmentAtlas.max_width
@ -585,9 +596,9 @@ class ScatterPlotItem(GraphicsObject):
recs['pen'][np.equal(recs['pen'], None)] = fn.mkPen(self.opts['pen']) recs['pen'][np.equal(recs['pen'], None)] = fn.mkPen(self.opts['pen'])
recs['brush'][np.equal(recs['brush'], None)] = fn.mkBrush(self.opts['brush']) recs['brush'][np.equal(recs['brush'], None)] = fn.mkBrush(self.opts['brush'])
return recs return recs
def measureSpotSizes(self, dataSet): def measureSpotSizes(self, dataSet):
for rec in dataSet: for rec in dataSet:
## keep track of the maximum spot size and pixel size ## keep track of the maximum spot size and pixel size
@ -605,8 +616,8 @@ class ScatterPlotItem(GraphicsObject):
self._maxSpotWidth = max(self._maxSpotWidth, width) self._maxSpotWidth = max(self._maxSpotWidth, width)
self._maxSpotPxWidth = max(self._maxSpotPxWidth, pxWidth) self._maxSpotPxWidth = max(self._maxSpotPxWidth, pxWidth)
self.bounds = [None, None] self.bounds = [None, None]
def clear(self): def clear(self):
"""Remove all spots from the scatter plot""" """Remove all spots from the scatter plot"""
#self.clearItems() #self.clearItems()
@ -617,23 +628,23 @@ class ScatterPlotItem(GraphicsObject):
def dataBounds(self, ax, frac=1.0, orthoRange=None): def dataBounds(self, ax, frac=1.0, orthoRange=None):
if frac >= 1.0 and orthoRange is None and self.bounds[ax] is not None: if frac >= 1.0 and orthoRange is None and self.bounds[ax] is not None:
return self.bounds[ax] return self.bounds[ax]
#self.prepareGeometryChange() #self.prepareGeometryChange()
if self.data is None or len(self.data) == 0: if self.data is None or len(self.data) == 0:
return (None, None) return (None, None)
if ax == 0: if ax == 0:
d = self.data['x'] d = self.data['x']
d2 = self.data['y'] d2 = self.data['y']
elif ax == 1: elif ax == 1:
d = self.data['y'] d = self.data['y']
d2 = self.data['x'] d2 = self.data['x']
if orthoRange is not None: if orthoRange is not None:
mask = (d2 >= orthoRange[0]) * (d2 <= orthoRange[1]) mask = (d2 >= orthoRange[0]) * (d2 <= orthoRange[1])
d = d[mask] d = d[mask]
d2 = d2[mask] d2 = d2[mask]
if frac >= 1.0: if frac >= 1.0:
self.bounds[ax] = (np.nanmin(d) - self._maxSpotWidth*0.7072, np.nanmax(d) + self._maxSpotWidth*0.7072) self.bounds[ax] = (np.nanmin(d) - self._maxSpotWidth*0.7072, np.nanmax(d) + self._maxSpotWidth*0.7072)
return self.bounds[ax] return self.bounds[ax]
@ -656,11 +667,11 @@ class ScatterPlotItem(GraphicsObject):
if ymn is None or ymx is None: if ymn is None or ymx is None:
ymn = 0 ymn = 0
ymx = 0 ymx = 0
px = py = 0.0 px = py = 0.0
pxPad = self.pixelPadding() pxPad = self.pixelPadding()
if pxPad > 0: if pxPad > 0:
# determine length of pixel in local x, y directions # determine length of pixel in local x, y directions
px, py = self.pixelVectors() px, py = self.pixelVectors()
try: try:
px = 0 if px is None else px.length() px = 0 if px is None else px.length()
@ -670,7 +681,7 @@ class ScatterPlotItem(GraphicsObject):
py = 0 if py is None else py.length() py = 0 if py is None else py.length()
except OverflowError: except OverflowError:
py = 0 py = 0
# return bounds expanded by pixel size # return bounds expanded by pixel size
px *= pxPad px *= pxPad
py *= pxPad py *= pxPad
@ -688,7 +699,7 @@ class ScatterPlotItem(GraphicsObject):
def mapPointsToDevice(self, pts): def mapPointsToDevice(self, pts):
# Map point locations to device # Map point locations to device
tr = self.deviceTransform() tr = self.deviceTransform()
if tr is None: if tr is None:
return None return None
@ -699,7 +710,7 @@ class ScatterPlotItem(GraphicsObject):
pts = fn.transformCoordinates(tr, pts) pts = fn.transformCoordinates(tr, pts)
pts -= self.data['width'] pts -= self.data['width']
pts = np.clip(pts, -2**30, 2**30) ## prevent Qt segmentation fault. pts = np.clip(pts, -2**30, 2**30) ## prevent Qt segmentation fault.
return pts return pts
def getViewMask(self, pts): def getViewMask(self, pts):
@ -713,48 +724,48 @@ class ScatterPlotItem(GraphicsObject):
mask = ((pts[0] + w > viewBounds.left()) & mask = ((pts[0] + w > viewBounds.left()) &
(pts[0] - w < viewBounds.right()) & (pts[0] - w < viewBounds.right()) &
(pts[1] + w > viewBounds.top()) & (pts[1] + w > viewBounds.top()) &
(pts[1] - w < viewBounds.bottom())) ## remove out of view points (pts[1] - w < viewBounds.bottom())) ## remove out of view points
return mask return mask
@debug.warnOnException ## raising an exception here causes crash @debug.warnOnException ## raising an exception here causes crash
def paint(self, p, *args): def paint(self, p, *args):
#p.setPen(fn.mkPen('r')) #p.setPen(fn.mkPen('r'))
#p.drawRect(self.boundingRect()) #p.drawRect(self.boundingRect())
if self._exportOpts is not False: if self._exportOpts is not False:
aa = self._exportOpts.get('antialias', True) aa = self._exportOpts.get('antialias', True)
scale = self._exportOpts.get('resolutionScale', 1.0) ## exporting to image; pixel resolution may have changed scale = self._exportOpts.get('resolutionScale', 1.0) ## exporting to image; pixel resolution may have changed
else: else:
aa = self.opts['antialias'] aa = self.opts['antialias']
scale = 1.0 scale = 1.0
if self.opts['pxMode'] is True: if self.opts['pxMode'] is True:
p.resetTransform() p.resetTransform()
# Map point coordinates to device # Map point coordinates to device
pts = np.vstack([self.data['x'], self.data['y']]) pts = np.vstack([self.data['x'], self.data['y']])
pts = self.mapPointsToDevice(pts) pts = self.mapPointsToDevice(pts)
if pts is None: if pts is None:
return return
# Cull points that are outside view # Cull points that are outside view
viewMask = self.getViewMask(pts) viewMask = self.getViewMask(pts)
#pts = pts[:,mask] #pts = pts[:,mask]
#data = self.data[mask] #data = self.data[mask]
if self.opts['useCache'] and self._exportOpts is False: if self.opts['useCache'] and self._exportOpts is False:
# Draw symbols from pre-rendered atlas # Draw symbols from pre-rendered atlas
atlas = self.fragmentAtlas.getAtlas() atlas = self.fragmentAtlas.getAtlas()
# Update targetRects if necessary # Update targetRects if necessary
updateMask = viewMask & np.equal(self.data['targetRect'], None) updateMask = viewMask & np.equal(self.data['targetRect'], None)
if np.any(updateMask): if np.any(updateMask):
updatePts = pts[:,updateMask] updatePts = pts[:,updateMask]
width = self.data[updateMask]['width']*2 width = self.data[updateMask]['width']*2
self.data['targetRect'][updateMask] = list(imap(QtCore.QRectF, updatePts[0,:], updatePts[1,:], width, width)) self.data['targetRect'][updateMask] = list(imap(QtCore.QRectF, updatePts[0,:], updatePts[1,:], width, width))
data = self.data[viewMask] data = self.data[viewMask]
if USE_PYSIDE or USE_PYQT5: if USE_PYSIDE or USE_PYQT5:
list(imap(p.drawPixmap, data['targetRect'], repeat(atlas), data['sourceRect'])) list(imap(p.drawPixmap, data['targetRect'], repeat(atlas), data['sourceRect']))
@ -782,16 +793,16 @@ class ScatterPlotItem(GraphicsObject):
p2.translate(rec['x'], rec['y']) p2.translate(rec['x'], rec['y'])
drawSymbol(p2, *self.getSpotOpts(rec, scale)) drawSymbol(p2, *self.getSpotOpts(rec, scale))
p2.end() p2.end()
p.setRenderHint(p.Antialiasing, aa) p.setRenderHint(p.Antialiasing, aa)
self.picture.play(p) self.picture.play(p)
def points(self): def points(self):
for rec in self.data: for rec in self.data:
if rec['item'] is None: if rec['item'] is None:
rec['item'] = SpotItem(rec, self) rec['item'] = SpotItem(rec, self)
return self.data['item'] return self.data['item']
def pointsAt(self, pos): def pointsAt(self, pos):
x = pos.x() x = pos.x()
y = pos.y() y = pos.y()
@ -814,7 +825,7 @@ class ScatterPlotItem(GraphicsObject):
#print "No hit:", (x, y), (sx, sy) #print "No hit:", (x, y), (sx, sy)
#print " ", (sx-s2x, sy-s2y), (sx+s2x, sy+s2y) #print " ", (sx-s2x, sy-s2y), (sx+s2x, sy+s2y)
return pts[::-1] return pts[::-1]
def mouseClickEvent(self, ev): def mouseClickEvent(self, ev):
if ev.button() == QtCore.Qt.LeftButton: if ev.button() == QtCore.Qt.LeftButton:
@ -833,7 +844,7 @@ class ScatterPlotItem(GraphicsObject):
class SpotItem(object): class SpotItem(object):
""" """
Class referring to individual spots in a scatter plot. Class referring to individual spots in a scatter plot.
These can be retrieved by calling ScatterPlotItem.points() or These can be retrieved by calling ScatterPlotItem.points() or
by connecting to the ScatterPlotItem's click signals. by connecting to the ScatterPlotItem's click signals.
""" """
@ -844,34 +855,34 @@ class SpotItem(object):
#self.setParentItem(plot) #self.setParentItem(plot)
#self.setPos(QtCore.QPointF(data['x'], data['y'])) #self.setPos(QtCore.QPointF(data['x'], data['y']))
#self.updateItem() #self.updateItem()
def data(self): def data(self):
"""Return the user data associated with this spot.""" """Return the user data associated with this spot."""
return self._data['data'] return self._data['data']
def size(self): def size(self):
"""Return the size of this spot. """Return the size of this spot.
If the spot has no explicit size set, then return the ScatterPlotItem's default size instead.""" If the spot has no explicit size set, then return the ScatterPlotItem's default size instead."""
if self._data['size'] == -1: if self._data['size'] == -1:
return self._plot.opts['size'] return self._plot.opts['size']
else: else:
return self._data['size'] return self._data['size']
def pos(self): def pos(self):
return Point(self._data['x'], self._data['y']) return Point(self._data['x'], self._data['y'])
def viewPos(self): def viewPos(self):
return self._plot.mapToView(self.pos()) return self._plot.mapToView(self.pos())
def setSize(self, size): def setSize(self, size):
"""Set the size of this spot. """Set the size of this spot.
If the size is set to -1, then the ScatterPlotItem's default size If the size is set to -1, then the ScatterPlotItem's default size
will be used instead.""" will be used instead."""
self._data['size'] = size self._data['size'] = size
self.updateItem() self.updateItem()
def symbol(self): def symbol(self):
"""Return the symbol of this spot. """Return the symbol of this spot.
If the spot has no explicit symbol set, then return the ScatterPlotItem's default symbol instead. If the spot has no explicit symbol set, then return the ScatterPlotItem's default symbol instead.
""" """
symbol = self._data['symbol'] symbol = self._data['symbol']
@ -883,7 +894,7 @@ class SpotItem(object):
except: except:
pass pass
return symbol return symbol
def setSymbol(self, symbol): def setSymbol(self, symbol):
"""Set the symbol for this spot. """Set the symbol for this spot.
If the symbol is set to '', then the ScatterPlotItem's default symbol will be used instead.""" If the symbol is set to '', then the ScatterPlotItem's default symbol will be used instead."""
@ -895,35 +906,35 @@ class SpotItem(object):
if pen is None: if pen is None:
pen = self._plot.opts['pen'] pen = self._plot.opts['pen']
return fn.mkPen(pen) return fn.mkPen(pen)
def setPen(self, *args, **kargs): def setPen(self, *args, **kargs):
"""Set the outline pen for this spot""" """Set the outline pen for this spot"""
pen = fn.mkPen(*args, **kargs) pen = fn.mkPen(*args, **kargs)
self._data['pen'] = pen self._data['pen'] = pen
self.updateItem() self.updateItem()
def resetPen(self): def resetPen(self):
"""Remove the pen set for this spot; the scatter plot's default pen will be used instead.""" """Remove the pen set for this spot; the scatter plot's default pen will be used instead."""
self._data['pen'] = None ## Note this is NOT the same as calling setPen(None) self._data['pen'] = None ## Note this is NOT the same as calling setPen(None)
self.updateItem() self.updateItem()
def brush(self): def brush(self):
brush = self._data['brush'] brush = self._data['brush']
if brush is None: if brush is None:
brush = self._plot.opts['brush'] brush = self._plot.opts['brush']
return fn.mkBrush(brush) return fn.mkBrush(brush)
def setBrush(self, *args, **kargs): def setBrush(self, *args, **kargs):
"""Set the fill brush for this spot""" """Set the fill brush for this spot"""
brush = fn.mkBrush(*args, **kargs) brush = fn.mkBrush(*args, **kargs)
self._data['brush'] = brush self._data['brush'] = brush
self.updateItem() self.updateItem()
def resetBrush(self): def resetBrush(self):
"""Remove the brush set for this spot; the scatter plot's default brush will be used instead.""" """Remove the brush set for this spot; the scatter plot's default brush will be used instead."""
self._data['brush'] = None ## Note this is NOT the same as calling setBrush(None) self._data['brush'] = None ## Note this is NOT the same as calling setBrush(None)
self.updateItem() self.updateItem()
def setData(self, data): def setData(self, data):
"""Set the user-data associated with this spot""" """Set the user-data associated with this spot"""
self._data['data'] = data self._data['data'] = data
@ -938,14 +949,14 @@ class SpotItem(object):
#QtGui.QGraphicsPixmapItem.__init__(self) #QtGui.QGraphicsPixmapItem.__init__(self)
#self.setFlags(self.flags() | self.ItemIgnoresTransformations) #self.setFlags(self.flags() | self.ItemIgnoresTransformations)
#SpotItem.__init__(self, data, plot) #SpotItem.__init__(self, data, plot)
#def setPixmap(self, pixmap): #def setPixmap(self, pixmap):
#QtGui.QGraphicsPixmapItem.setPixmap(self, pixmap) #QtGui.QGraphicsPixmapItem.setPixmap(self, pixmap)
#self.setOffset(-pixmap.width()/2.+0.5, -pixmap.height()/2.) #self.setOffset(-pixmap.width()/2.+0.5, -pixmap.height()/2.)
#def updateItem(self): #def updateItem(self):
#symbolOpts = (self._data['pen'], self._data['brush'], self._data['size'], self._data['symbol']) #symbolOpts = (self._data['pen'], self._data['brush'], self._data['size'], self._data['symbol'])
### If all symbol options are default, use default pixmap ### If all symbol options are default, use default pixmap
#if symbolOpts == (None, None, -1, ''): #if symbolOpts == (None, None, -1, ''):
#pixmap = self._plot.defaultSpotPixmap() #pixmap = self._plot.defaultSpotPixmap()

View File

@ -1,13 +1,16 @@
import numpy as np
from ..Qt import QtCore, QtGui from ..Qt import QtCore, QtGui
from ..Point import Point from ..Point import Point
from .UIGraphicsItem import *
from .. import functions as fn from .. import functions as fn
from .GraphicsObject import GraphicsObject
class TextItem(UIGraphicsItem):
class TextItem(GraphicsObject):
""" """
GraphicsItem displaying unscaled text (the text will always appear normal even inside a scaled ViewBox). GraphicsItem displaying unscaled text (the text will always appear normal even inside a scaled ViewBox).
""" """
def __init__(self, text='', color=(200,200,200), html=None, anchor=(0,0), border=None, fill=None, angle=0): def __init__(self, text='', color=(200,200,200), html=None, anchor=(0,0),
border=None, fill=None, angle=0, rotateAxis=None):
""" """
============== ================================================================================= ============== =================================================================================
**Arguments:** **Arguments:**
@ -20,19 +23,31 @@ class TextItem(UIGraphicsItem):
sets the lower-right corner. sets the lower-right corner.
*border* A pen to use when drawing the border *border* A pen to use when drawing the border
*fill* A brush to use when filling within the border *fill* A brush to use when filling within the border
*angle* Angle in degrees to rotate text. Default is 0; text will be displayed upright.
*rotateAxis* If None, then a text angle of 0 always points along the +x axis of the scene.
If a QPointF or (x,y) sequence is given, then it represents a vector direction
in the parent's coordinate system that the 0-degree line will be aligned to. This
Allows text to follow both the position and orientation of its parent while still
discarding any scale and shear factors.
============== ================================================================================= ============== =================================================================================
The effects of the `rotateAxis` and `angle` arguments are added independently. So for example:
* rotateAxis=None, angle=0 -> normal horizontal text
* rotateAxis=None, angle=90 -> normal vertical text
* rotateAxis=(1, 0), angle=0 -> text aligned with x axis of its parent
* rotateAxis=(0, 1), angle=0 -> text aligned with y axis of its parent
* rotateAxis=(1, 0), angle=90 -> text orthogonal to x axis of its parent
""" """
## not working yet
#*angle* Angle in degrees to rotate text (note that the rotation assigned in this item's
#transformation will be ignored)
self.anchor = Point(anchor) self.anchor = Point(anchor)
self.rotateAxis = None if rotateAxis is None else Point(rotateAxis)
#self.angle = 0 #self.angle = 0
UIGraphicsItem.__init__(self) GraphicsObject.__init__(self)
self.textItem = QtGui.QGraphicsTextItem() self.textItem = QtGui.QGraphicsTextItem()
self.textItem.setParentItem(self) self.textItem.setParentItem(self)
self.lastTransform = None self._lastTransform = None
self._bounds = QtCore.QRectF() self._bounds = QtCore.QRectF()
if html is None: if html is None:
self.setText(text, color) self.setText(text, color)
@ -40,8 +55,7 @@ class TextItem(UIGraphicsItem):
self.setHtml(html) self.setHtml(html)
self.fill = fn.mkBrush(fill) self.fill = fn.mkBrush(fill)
self.border = fn.mkPen(border) self.border = fn.mkPen(border)
self.rotate(angle) self.setAngle(angle)
self.setFlag(self.ItemIgnoresTransformations) ## This is required to keep the text unscaled inside the viewport
def setText(self, text, color=(200,200,200)): def setText(self, text, color=(200,200,200)):
""" """
@ -52,14 +66,7 @@ class TextItem(UIGraphicsItem):
color = fn.mkColor(color) color = fn.mkColor(color)
self.textItem.setDefaultTextColor(color) self.textItem.setDefaultTextColor(color)
self.textItem.setPlainText(text) self.textItem.setPlainText(text)
self.updateText() self.updateTextPos()
#html = '<span style="color: #%s; text-align: center;">%s</span>' % (color, text)
#self.setHtml(html)
def updateAnchor(self):
pass
#self.resetTransform()
#self.translate(0, 20)
def setPlainText(self, *args): def setPlainText(self, *args):
""" """
@ -68,7 +75,7 @@ class TextItem(UIGraphicsItem):
See QtGui.QGraphicsTextItem.setPlainText(). See QtGui.QGraphicsTextItem.setPlainText().
""" """
self.textItem.setPlainText(*args) self.textItem.setPlainText(*args)
self.updateText() self.updateTextPos()
def setHtml(self, *args): def setHtml(self, *args):
""" """
@ -77,7 +84,7 @@ class TextItem(UIGraphicsItem):
See QtGui.QGraphicsTextItem.setHtml(). See QtGui.QGraphicsTextItem.setHtml().
""" """
self.textItem.setHtml(*args) self.textItem.setHtml(*args)
self.updateText() self.updateTextPos()
def setTextWidth(self, *args): def setTextWidth(self, *args):
""" """
@ -89,7 +96,7 @@ class TextItem(UIGraphicsItem):
See QtGui.QGraphicsTextItem.setTextWidth(). See QtGui.QGraphicsTextItem.setTextWidth().
""" """
self.textItem.setTextWidth(*args) self.textItem.setTextWidth(*args)
self.updateText() self.updateTextPos()
def setFont(self, *args): def setFont(self, *args):
""" """
@ -98,50 +105,43 @@ class TextItem(UIGraphicsItem):
See QtGui.QGraphicsTextItem.setFont(). See QtGui.QGraphicsTextItem.setFont().
""" """
self.textItem.setFont(*args) self.textItem.setFont(*args)
self.updateText() self.updateTextPos()
#def setAngle(self, angle): def setAngle(self, angle):
#self.angle = angle self.angle = angle
#self.updateText() self.updateTransform()
def setAnchor(self, anchor):
self.anchor = Point(anchor)
self.updateTextPos()
def updateText(self): def updateTextPos(self):
# update text position to obey anchor
r = self.textItem.boundingRect()
tl = self.textItem.mapToParent(r.topLeft())
br = self.textItem.mapToParent(r.bottomRight())
offset = (br - tl) * self.anchor
self.textItem.setPos(-offset)
## Needed to maintain font size when rendering to image with increased resolution ### Needed to maintain font size when rendering to image with increased resolution
self.textItem.resetTransform() #self.textItem.resetTransform()
#self.textItem.rotate(self.angle) ##self.textItem.rotate(self.angle)
if self._exportOpts is not False and 'resolutionScale' in self._exportOpts: #if self._exportOpts is not False and 'resolutionScale' in self._exportOpts:
s = self._exportOpts['resolutionScale'] #s = self._exportOpts['resolutionScale']
self.textItem.scale(s, s) #self.textItem.scale(s, s)
#br = self.textItem.mapRectToParent(self.textItem.boundingRect())
self.textItem.setPos(0,0)
br = self.textItem.boundingRect()
apos = self.textItem.mapToParent(Point(br.width()*self.anchor.x(), br.height()*self.anchor.y()))
#print br, apos
self.textItem.setPos(-apos.x(), -apos.y())
#def textBoundingRect(self):
### return the bounds of the text box in device coordinates
#pos = self.mapToDevice(QtCore.QPointF(0,0))
#if pos is None:
#return None
#tbr = self.textItem.boundingRect()
#return QtCore.QRectF(pos.x() - tbr.width()*self.anchor.x(), pos.y() - tbr.height()*self.anchor.y(), tbr.width(), tbr.height())
def viewRangeChanged(self):
self.updateText()
def boundingRect(self): def boundingRect(self):
return self.textItem.mapToParent(self.textItem.boundingRect()).boundingRect() return self.textItem.mapToParent(self.textItem.boundingRect()).boundingRect()
def viewTransformChanged(self):
# called whenever view transform has changed.
# Do this here to avoid double-updates when view changes.
self.updateTransform()
def paint(self, p, *args): def paint(self, p, *args):
tr = p.transform() # this is not ideal because it causes another update to be scheduled.
if self.lastTransform is not None: # ideally, we would have a sceneTransformChanged event to react to..
if tr != self.lastTransform: self.updateTransform()
self.viewRangeChanged()
self.lastTransform = tr
if self.border.style() != QtCore.Qt.NoPen or self.fill.style() != QtCore.Qt.NoBrush: if self.border.style() != QtCore.Qt.NoPen or self.fill.style() != QtCore.Qt.NoBrush:
p.setPen(self.border) p.setPen(self.border)
@ -149,4 +149,37 @@ class TextItem(UIGraphicsItem):
p.setRenderHint(p.Antialiasing, True) p.setRenderHint(p.Antialiasing, True)
p.drawPolygon(self.textItem.mapToParent(self.textItem.boundingRect())) p.drawPolygon(self.textItem.mapToParent(self.textItem.boundingRect()))
def updateTransform(self):
# update transform such that this item has the correct orientation
# and scaling relative to the scene, but inherits its position from its
# parent.
# This is similar to setting ItemIgnoresTransformations = True, but
# does not break mouse interaction and collision detection.
p = self.parentItem()
if p is None:
pt = QtGui.QTransform()
else:
pt = p.sceneTransform()
if pt == self._lastTransform:
return
t = pt.inverted()[0]
# reset translation
t.setMatrix(t.m11(), t.m12(), t.m13(), t.m21(), t.m22(), t.m23(), 0, 0, t.m33())
# apply rotation
angle = -self.angle
if self.rotateAxis is not None:
d = pt.map(self.rotateAxis) - pt.map(Point(0, 0))
a = np.arctan2(d.y(), d.x()) * 180 / np.pi
angle += a
t.rotate(angle)
self.setTransform(t)
self._lastTransform = pt
self.updateTextPos()

View File

@ -0,0 +1,96 @@
import pyqtgraph as pg
from pyqtgraph.Qt import QtGui, QtCore, QtTest
from pyqtgraph.tests import mouseDrag, mouseMove
pg.mkQApp()
def test_InfiniteLine():
# Test basic InfiniteLine API
plt = pg.plot()
plt.setXRange(-10, 10)
plt.setYRange(-10, 10)
plt.resize(600, 600)
# seemingly arbitrary requirements; might need longer wait time for some platforms..
QtTest.QTest.qWaitForWindowShown(plt)
QtTest.QTest.qWait(100)
vline = plt.addLine(x=1)
assert vline.angle == 90
br = vline.mapToView(QtGui.QPolygonF(vline.boundingRect()))
assert br.containsPoint(pg.Point(1, 5), QtCore.Qt.OddEvenFill)
assert not br.containsPoint(pg.Point(5, 0), QtCore.Qt.OddEvenFill)
hline = plt.addLine(y=0)
assert hline.angle == 0
assert hline.boundingRect().contains(pg.Point(5, 0))
assert not hline.boundingRect().contains(pg.Point(0, 5))
vline.setValue(2)
assert vline.value() == 2
vline.setPos(pg.Point(4, -5))
assert vline.value() == 4
oline = pg.InfiniteLine(angle=30)
plt.addItem(oline)
oline.setPos(pg.Point(1, -1))
assert oline.angle == 30
assert oline.pos() == pg.Point(1, -1)
assert oline.value() == [1, -1]
# test bounding rect for oblique line
br = oline.mapToScene(oline.boundingRect())
pos = oline.mapToScene(pg.Point(2, 0))
assert br.containsPoint(pos, QtCore.Qt.OddEvenFill)
px = pg.Point(-0.5, -1.0 / 3**0.5)
assert br.containsPoint(pos + 5 * px, QtCore.Qt.OddEvenFill)
assert not br.containsPoint(pos + 7 * px, QtCore.Qt.OddEvenFill)
def test_mouseInteraction():
plt = pg.plot()
plt.scene().minDragTime = 0 # let us simulate mouse drags very quickly.
vline = plt.addLine(x=0, movable=True)
plt.addItem(vline)
hline = plt.addLine(y=0, movable=True)
hline2 = plt.addLine(y=-1, movable=False)
plt.setXRange(-10, 10)
plt.setYRange(-10, 10)
# test horizontal drag
pos = plt.plotItem.vb.mapViewToScene(pg.Point(0,5)).toPoint()
pos2 = pos - QtCore.QPoint(200, 200)
mouseMove(plt, pos)
assert vline.mouseHovering is True and hline.mouseHovering is False
mouseDrag(plt, pos, pos2, QtCore.Qt.LeftButton)
px = vline.pixelLength(pg.Point(1, 0), ortho=True)
assert abs(vline.value() - plt.plotItem.vb.mapSceneToView(pos2).x()) <= px
# test missed drag
pos = plt.plotItem.vb.mapViewToScene(pg.Point(5,0)).toPoint()
pos = pos + QtCore.QPoint(0, 6)
pos2 = pos + QtCore.QPoint(-20, -20)
mouseMove(plt, pos)
assert vline.mouseHovering is False and hline.mouseHovering is False
mouseDrag(plt, pos, pos2, QtCore.Qt.LeftButton)
assert hline.value() == 0
# test vertical drag
pos = plt.plotItem.vb.mapViewToScene(pg.Point(5,0)).toPoint()
pos2 = pos - QtCore.QPoint(50, 50)
mouseMove(plt, pos)
assert vline.mouseHovering is False and hline.mouseHovering is True
mouseDrag(plt, pos, pos2, QtCore.Qt.LeftButton)
px = hline.pixelLength(pg.Point(1, 0), ortho=True)
assert abs(hline.value() - plt.plotItem.vb.mapSceneToView(pos2).y()) <= px
# test non-interactive line
pos = plt.plotItem.vb.mapViewToScene(pg.Point(5,-1)).toPoint()
pos2 = pos - QtCore.QPoint(50, 50)
mouseMove(plt, pos)
assert hline2.mouseHovering == False
mouseDrag(plt, pos, pos2, QtCore.Qt.LeftButton)
assert hline2.value() == -1
if __name__ == '__main__':
test_mouseInteraction()

View File

@ -1 +1,2 @@
from .image_testing import assertImageApproved from .image_testing import assertImageApproved
from .ui_testing import mousePress, mouseMove, mouseRelease, mouseDrag, mouseClick

View File

@ -0,0 +1,55 @@
# Functions for generating user input events.
# We would like to use QTest for this purpose, but it seems to be broken.
# See: http://stackoverflow.com/questions/16299779/qt-qgraphicsview-unit-testing-how-to-keep-the-mouse-in-a-pressed-state
from ..Qt import QtCore, QtGui, QT_LIB
def mousePress(widget, pos, button, modifier=None):
if isinstance(widget, QtGui.QGraphicsView):
widget = widget.viewport()
if modifier is None:
modifier = QtCore.Qt.NoModifier
if QT_LIB != 'PyQt5' and isinstance(pos, QtCore.QPointF):
pos = pos.toPoint()
event = QtGui.QMouseEvent(QtCore.QEvent.MouseButtonPress, pos, button, QtCore.Qt.NoButton, modifier)
QtGui.QApplication.sendEvent(widget, event)
def mouseRelease(widget, pos, button, modifier=None):
if isinstance(widget, QtGui.QGraphicsView):
widget = widget.viewport()
if modifier is None:
modifier = QtCore.Qt.NoModifier
if QT_LIB != 'PyQt5' and isinstance(pos, QtCore.QPointF):
pos = pos.toPoint()
event = QtGui.QMouseEvent(QtCore.QEvent.MouseButtonRelease, pos, button, QtCore.Qt.NoButton, modifier)
QtGui.QApplication.sendEvent(widget, event)
def mouseMove(widget, pos, buttons=None, modifier=None):
if isinstance(widget, QtGui.QGraphicsView):
widget = widget.viewport()
if modifier is None:
modifier = QtCore.Qt.NoModifier
if buttons is None:
buttons = QtCore.Qt.NoButton
if QT_LIB != 'PyQt5' and isinstance(pos, QtCore.QPointF):
pos = pos.toPoint()
event = QtGui.QMouseEvent(QtCore.QEvent.MouseMove, pos, QtCore.Qt.NoButton, buttons, modifier)
QtGui.QApplication.sendEvent(widget, event)
def mouseDrag(widget, pos1, pos2, button, modifier=None):
mouseMove(widget, pos1)
mousePress(widget, pos1, button, modifier)
mouseMove(widget, pos2, button, modifier)
mouseRelease(widget, pos2, button, modifier)
def mouseClick(widget, pos, button, modifier=None):
mouseMove(widget, pos)
mousePress(widget, pos, button, modifier)
mouseRelease(widget, pos, button, modifier)