diff --git a/examples/InfiniteLine.py b/examples/InfiniteLine.py
new file mode 100644
index 00000000..50efbd04
--- /dev/null
+++ b/examples/InfiniteLine.py
@@ -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_()
diff --git a/examples/Symbols.py b/examples/Symbols.py
new file mode 100755
index 00000000..3dd28e13
--- /dev/null
+++ b/examples/Symbols.py
@@ -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_()
diff --git a/examples/infiniteline_performance.py b/examples/infiniteline_performance.py
new file mode 100644
index 00000000..86264142
--- /dev/null
+++ b/examples/infiniteline_performance.py
@@ -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_()
\ No newline at end of file
diff --git a/examples/text.py b/examples/text.py
index 23f527e3..43302e96 100644
--- a/examples/text.py
+++ b/examples/text.py
@@ -23,7 +23,7 @@ plot.setWindowTitle('pyqtgraph example: text')
curve = plot.plot(x,y) ## add a single curve
## Create text object, use HTML tags to specify color/size
-text = pg.TextItem(html='
This is the
PEAK
', anchor=(-0.3,1.3), border='w', fill=(0, 0, 255, 100))
+text = pg.TextItem(html='This is the
PEAK
', anchor=(-0.3,0.5), angle=45, border='w', fill=(0, 0, 255, 100))
plot.addItem(text)
text.setPos(0, y.max())
diff --git a/examples/utils.py b/examples/utils.py
index 3ff265c4..cbdf69c6 100644
--- a/examples/utils.py
+++ b/examples/utils.py
@@ -22,6 +22,7 @@ examples = OrderedDict([
('Console', 'ConsoleWidget.py'),
('Histograms', 'histogram.py'),
('Beeswarm plot', 'beeswarm.py'),
+ ('Symbols', 'Symbols.py'),
('Auto-range', 'PlotAutoRange.py'),
('Remote Plotting', 'RemoteSpeedTest.py'),
('Scrolling plots', 'scrollingPlots.py'),
diff --git a/pyqtgraph/GraphicsScene/GraphicsScene.py b/pyqtgraph/GraphicsScene/GraphicsScene.py
index 840e3135..bab0f776 100644
--- a/pyqtgraph/GraphicsScene/GraphicsScene.py
+++ b/pyqtgraph/GraphicsScene/GraphicsScene.py
@@ -98,6 +98,7 @@ class GraphicsScene(QtGui.QGraphicsScene):
self.lastDrag = None
self.hoverItems = weakref.WeakKeyDictionary()
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[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
cev = [e for e in self.clickEvents if int(e.button()) == int(btn)][0]
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
init = init or (len(self.dragButtons) == 0) ## If this is the first button to be dragged, then init=True
self.dragButtons.append(int(btn))
@@ -186,10 +187,8 @@ class GraphicsScene(QtGui.QGraphicsScene):
def leaveEvent(self, ev): ## inform items that mouse is gone
if len(self.dragButtons) == 0:
self.sendHoverEvents(ev, exitOnly=True)
-
def mouseReleaseEvent(self, ev):
- #print 'sceneRelease'
if self.mouseGrabberItem() is None:
if ev.button() in self.dragButtons:
if self.sendDragEvent(ev, final=True):
diff --git a/pyqtgraph/Qt.py b/pyqtgraph/Qt.py
index 3584bec0..c9700784 100644
--- a/pyqtgraph/Qt.py
+++ b/pyqtgraph/Qt.py
@@ -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
@@ -45,6 +45,15 @@ if QT_LIB == PYSIDE:
from PySide import QtGui, QtCore, QtOpenGL, QtSvg
try:
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:
pass
import PySide
diff --git a/pyqtgraph/graphicsItems/InfiniteLine.py b/pyqtgraph/graphicsItems/InfiniteLine.py
index 240dfe97..b76b4483 100644
--- a/pyqtgraph/graphicsItems/InfiniteLine.py
+++ b/pyqtgraph/graphicsItems/InfiniteLine.py
@@ -1,19 +1,23 @@
from ..Qt import QtGui, QtCore
from ..Point import Point
from .GraphicsObject import GraphicsObject
+from .TextItem import TextItem
+from .ViewBox import ViewBox
from .. import functions as fn
import numpy as np
import weakref
-__all__ = ['InfiniteLine']
+__all__ = ['InfiniteLine', 'InfLineLabel']
+
+
class InfiniteLine(GraphicsObject):
"""
**Bases:** :class:`GraphicsObject `
-
+
Displays a line of infinite length.
This line may be dragged to indicate a position in data coordinates.
-
+
=============================== ===================================================
**Signals:**
sigDragged(self)
@@ -21,12 +25,13 @@ class InfiniteLine(GraphicsObject):
sigPositionChanged(self)
=============================== ===================================================
"""
-
+
sigDragged = QtCore.Signal(object)
sigPositionChangeFinished = 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:**
@@ -37,13 +42,26 @@ class InfiniteLine(GraphicsObject):
for :func:`mkPen `. Default pen is transparent
yellow.
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 `.
+ Default pen is red.
bounds Optional [min, max] bounding values. Bounds are only valid if the
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)
-
+
if bounds is None: ## allowed value boundaries for orthogonal lines
self.maxRange = [None, None]
else:
@@ -53,63 +71,70 @@ class InfiniteLine(GraphicsObject):
self.mouseHovering = False
self.p = [0, 0]
self.setAngle(angle)
+
if pos is None:
pos = Point(0,0)
self.setPos(pos)
if pen is None:
pen = (200, 200, 100)
-
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
-
+
+ if label is not None:
+ labelOpts = {} if labelOpts is None else labelOpts
+ self.label = InfLineLabel(self, text=label, **labelOpts)
+
def setMovable(self, m):
"""Set whether the line is movable by the user."""
self.movable = m
self.setAcceptHoverEvents(m)
-
+
def setBounds(self, bounds):
"""Set the (minimum, maximum) allowable values when dragging."""
self.maxRange = bounds
self.setValue(self.value())
-
+
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 `."""
self.pen = fn.mkPen(*args, **kwargs)
if not self.mouseHovering:
self.currentPen = self.pen
self.update()
-
+
def setHoverPen(self, *args, **kwargs):
- """Set the pen for drawing the line while the mouse hovers over it.
- Allowable arguments are any that are valid
+ """Set the pen for drawing the line while the mouse hovers over it.
+ Allowable arguments are any that are valid
for :func:`mkPen `.
-
+
If the line is not movable, then hovering is also disabled.
-
+
Added in version 0.9.9."""
self.hoverPen = fn.mkPen(*args, **kwargs)
if self.mouseHovering:
self.currentPen = self.hoverPen
self.update()
-
+
def setAngle(self, angle):
"""
Takes angle argument in degrees.
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.
"""
self.angle = ((angle+45) % 180) - 45 ## -45 <= angle < 135
self.resetTransform()
self.rotate(self.angle)
self.update()
-
+
def setPos(self, pos):
-
+
if type(pos) in [list, tuple]:
newPos = pos
elif isinstance(pos, QtCore.QPointF):
@@ -121,10 +146,10 @@ class InfiniteLine(GraphicsObject):
newPos = [0, pos]
else:
raise Exception("Must specify 2D coordinate for non-orthogonal lines.")
-
+
## check bounds (only works for orthogonal lines)
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])
if self.maxRange[1] is not None:
newPos[0] = min(newPos[0], self.maxRange[1])
@@ -133,24 +158,24 @@ class InfiniteLine(GraphicsObject):
newPos[1] = max(newPos[1], self.maxRange[0])
if self.maxRange[1] is not None:
newPos[1] = min(newPos[1], self.maxRange[1])
-
+
if self.p != newPos:
self.p = newPos
+ self._invalidateCache()
GraphicsObject.setPos(self, Point(self.p))
- self.update()
self.sigPositionChanged.emit(self)
def getXPos(self):
return self.p[0]
-
+
def getYPos(self):
return self.p[1]
-
+
def getPos(self):
return self.p
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."""
if self.angle%180 == 0:
return self.getYPos()
@@ -158,10 +183,10 @@ class InfiniteLine(GraphicsObject):
return self.getXPos()
else:
return self.getPos()
-
+
def setValue(self, v):
- """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
+ """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
QPointF are all acceptable)."""
self.setPos(v)
@@ -174,25 +199,35 @@ class InfiniteLine(GraphicsObject):
#else:
#print "ignore", change
#return GraphicsObject.itemChange(self, change, val)
-
+
+ def _invalidateCache(self):
+ self._line = None
+ self._boundingRect = None
+
def boundingRect(self):
- #br = UIGraphicsItem.boundingRect(self)
- br = self.viewRect()
- ## add a 4-pixel radius around the line for mouse interaction.
-
- px = self.pixelLength(direction=Point(1,0), ortho=True) ## get pixel length orthogonal to the line
- if px is None:
- px = 0
- w = (max(4, self.pen.width()/2, self.hoverPen.width()/2)+1) * px
- br.setBottom(-w)
- br.setTop(w)
- return br.normalized()
-
+ if self._boundingRect is None:
+ #br = UIGraphicsItem.boundingRect(self)
+ br = self.viewRect()
+ if br is None:
+ return QtCore.QRectF()
+
+ ## add a 4-pixel radius around the line for mouse interaction.
+ px = self.pixelLength(direction=Point(1,0), ortho=True) ## get pixel length orthogonal to the line
+ if px is None:
+ px = 0
+ 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):
- br = self.boundingRect()
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):
if axis == 0:
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.startPosition = self.pos()
ev.accept()
-
+
if not self.moving:
return
-
+
self.setPos(self.cursorOffset + self.mapToParent(ev.pos()))
self.sigDragged.emit(self)
if ev.isFinish():
self.moving = False
self.sigPositionChangeFinished.emit(self)
-
+
def mouseClickEvent(self, ev):
if self.moving and ev.button() == QtCore.Qt.RightButton:
ev.accept()
@@ -240,3 +275,195 @@ class InfiniteLine(GraphicsObject):
else:
self.currentPen = self.pen
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())
diff --git a/pyqtgraph/graphicsItems/ScatterPlotItem.py b/pyqtgraph/graphicsItems/ScatterPlotItem.py
index 89f068ce..54667b50 100644
--- a/pyqtgraph/graphicsItems/ScatterPlotItem.py
+++ b/pyqtgraph/graphicsItems/ScatterPlotItem.py
@@ -19,17 +19,28 @@ __all__ = ['ScatterPlotItem', 'SpotItem']
## 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['s'].addRect(QtCore.QRectF(-0.5, -0.5, 1, 1))
coords = {
'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)],
'+': [
(-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)
],
+ '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():
Symbols[k].moveTo(*c[0])
@@ -40,7 +51,7 @@ tr = QtGui.QTransform()
tr.rotate(45)
Symbols['x'] = tr.map(Symbols['+'])
-
+
def drawSymbol(painter, symbol, size, pen, brush):
if symbol is None:
return
@@ -53,13 +64,13 @@ def drawSymbol(painter, symbol, size, pen, brush):
symbol = list(Symbols.values())[symbol % len(Symbols)]
painter.drawPath(symbol)
-
+
def renderSymbol(symbol, size, pen, brush, device=None):
"""
Render a symbol specification to QImage.
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,
- 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).
"""
## Render a spot with the given parameters to a pixmap
@@ -80,33 +91,33 @@ def makeSymbolPixmap(size, pen, brush, symbol):
## deprecated
img = renderSymbol(symbol, size, pen, brush)
return QtGui.QPixmap(img)
-
+
class SymbolAtlas(object):
"""
Used to efficiently construct a single QPixmap containing all rendered symbols
for a ScatterPlotItem. This is required for fragment rendering.
-
+
Use example:
atlas = SymbolAtlas()
sc1 = atlas.getSymbolCoords('o', 5, QPen(..), QBrush(..))
sc2 = atlas.getSymbolCoords('t', 10, QPen(..), QBrush(..))
pm = atlas.getAtlas()
-
+
"""
def __init__(self):
# 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
# 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.
self.symbolMap = weakref.WeakValueDictionary()
-
+
self.atlasData = None # numpy array of atlas image
self.atlas = None # atlas as QPixmap
self.atlasValid = False
self.max_width=0
-
+
def getSymbolCoords(self, opts):
"""
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
sourceRecti = newRectSrc
return sourceRect
-
+
def buildAtlas(self):
# get rendered array for all symbols, keep track of avg/max width
rendered = {}
@@ -150,7 +161,7 @@ class SymbolAtlas(object):
w = arr.shape[0]
avgWidth += w
maxWidth = max(maxWidth, w)
-
+
nSymbols = len(rendered)
if nSymbols > 0:
avgWidth /= nSymbols
@@ -158,10 +169,10 @@ class SymbolAtlas(object):
else:
avgWidth = 0
width = 0
-
+
# sort symbols by height
symbols = sorted(rendered.keys(), key=lambda x: rendered[x].shape[1], reverse=True)
-
+
self.atlasRows = []
x = width
@@ -187,7 +198,7 @@ class SymbolAtlas(object):
self.atlas = None
self.atlasValid = True
self.max_width = maxWidth
-
+
def getAtlas(self):
if not self.atlasValid:
self.buildAtlas()
@@ -197,27 +208,27 @@ class SymbolAtlas(object):
img = fn.makeQImage(self.atlasData, copy=False, transpose=False)
self.atlas = QtGui.QPixmap(img)
return self.atlas
-
-
-
-
+
+
+
+
class ScatterPlotItem(GraphicsObject):
"""
Displays a set of x/y points. Instances of this class are created
automatically as part of PlotDataItem; these rarely need to be instantiated
directly.
-
- The size, shape, pen, and fill brush may be set for each point individually
- or for all points.
-
-
+
+ The size, shape, pen, and fill brush may be set for each point individually
+ or for all points.
+
+
======================== ===============================================
**Signals:**
sigPlotChanged(self) Emitted when the data being plotted has changed
sigClicked(self, points) Emitted when the curve is clicked. Sends a list
of all the points under the mouse pointer.
======================== ===============================================
-
+
"""
#sigPointClicked = QtCore.Signal(object, object)
sigClicked = QtCore.Signal(object, object) ## self, points
@@ -228,17 +239,17 @@ class ScatterPlotItem(GraphicsObject):
"""
profiler = debug.Profiler()
GraphicsObject.__init__(self)
-
+
self.picture = None # QPicture used for rendering when pxmode==False
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.bounds = [None, None] ## caches data bounds
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.opts = {
- 'pxMode': True,
- 'useCache': True, ## If useCache is False, symbols are re-drawn on every paint.
+ 'pxMode': True,
+ 'useCache': True, ## If useCache is False, symbols are re-drawn on every paint.
'antialias': getConfigOption('antialias'),
'name': None,
}
@@ -252,14 +263,14 @@ class ScatterPlotItem(GraphicsObject):
profiler('setData')
#self.setCacheMode(self.DeviceCoordinateCache)
-
+
def setData(self, *args, **kargs):
"""
**Ordered Arguments:**
-
+
* 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.
-
+
====================== ===============================================================================================
**Keyword Arguments:**
*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.
*data* a list of python objects used to uniquely identify each spot.
*identical* *Deprecated*. This functionality is handled automatically now.
- *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
+ *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
incurs very little performance cost)
*name* The name of this item. Names are used for automatically
generating LegendItem entries and by some exporters.
@@ -298,10 +309,10 @@ class ScatterPlotItem(GraphicsObject):
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()
"""
-
+
## deal with non-keyword arguments
if len(args) == 1:
kargs['spots'] = args[0]
@@ -310,7 +321,7 @@ class ScatterPlotItem(GraphicsObject):
kargs['y'] = args[1]
elif len(args) > 2:
raise Exception('Only accepts up to two non-keyword arguments.')
-
+
## convert 'pos' argument to 'x' and 'y'
if 'pos' in kargs:
pos = kargs['pos']
@@ -329,7 +340,7 @@ class ScatterPlotItem(GraphicsObject):
y.append(p[1])
kargs['x'] = x
kargs['y'] = y
-
+
## determine how many spots we have
if 'spots' in kargs:
numPts = len(kargs['spots'])
@@ -339,16 +350,16 @@ class ScatterPlotItem(GraphicsObject):
kargs['x'] = []
kargs['y'] = []
numPts = 0
-
+
## Extend record array
oldData = self.data
self.data = np.empty(len(oldData)+numPts, dtype=self.data.dtype)
## note that np.empty initializes object fields to None and string fields to ''
-
+
self.data[:len(oldData)] = oldData
#for i in range(len(oldData)):
#oldData[i]['item']._data = self.data[i] ## Make sure items have proper reference to new array
-
+
newData = self.data[len(oldData):]
newData['size'] = -1 ## indicates to use default size
@@ -376,12 +387,12 @@ class ScatterPlotItem(GraphicsObject):
elif 'y' in kargs:
newData['x'] = kargs['x']
newData['y'] = kargs['y']
-
+
if 'pxMode' in kargs:
self.setPxMode(kargs['pxMode'])
if 'antialias' in kargs:
self.opts['antialias'] = kargs['antialias']
-
+
## Set any extra parameters provided in keyword arguments
for k in ['pen', 'brush', 'symbol', 'size']:
if k in kargs:
@@ -397,32 +408,32 @@ class ScatterPlotItem(GraphicsObject):
self.invalidate()
self.updateSpots(newData)
self.sigPlotChanged.emit(self)
-
+
def invalidate(self):
## clear any cached drawing state
self.picture = None
self.update()
-
+
def getData(self):
- return self.data['x'], self.data['y']
-
+ return self.data['x'], self.data['y']
+
def setPoints(self, *args, **kargs):
##Deprecated; use setData
return self.setData(*args, **kargs)
-
+
def implements(self, interface=None):
ints = ['plotData']
if interface is None:
return ints
return interface in ints
-
+
def name(self):
return self.opts.get('name', None)
-
+
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.
- 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."""
update = kargs.pop('update', True)
dataSet = kargs.pop('dataSet', self.data)
@@ -436,19 +447,19 @@ class ScatterPlotItem(GraphicsObject):
dataSet['pen'] = pens
else:
self.opts['pen'] = fn.mkPen(*args, **kargs)
-
+
dataSet['sourceRect'] = None
if update:
self.updateSpots(dataSet)
-
+
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.
- 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."""
update = kargs.pop('update', True)
dataSet = kargs.pop('dataSet', self.data)
-
+
if len(args) == 1 and (isinstance(args[0], np.ndarray) or isinstance(args[0], list)):
brushes = args[0]
if 'mask' in kargs and kargs['mask'] is not None:
@@ -459,19 +470,19 @@ class ScatterPlotItem(GraphicsObject):
else:
self.opts['brush'] = fn.mkBrush(*args, **kargs)
#self._spotPixmap = None
-
+
dataSet['sourceRect'] = None
if update:
self.updateSpots(dataSet)
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.
- 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."""
if dataSet is None:
dataSet = self.data
-
+
if isinstance(symbol, np.ndarray) or isinstance(symbol, list):
symbols = symbol
if mask is not None:
@@ -482,19 +493,19 @@ class ScatterPlotItem(GraphicsObject):
else:
self.opts['symbol'] = symbol
self._spotPixmap = None
-
+
dataSet['sourceRect'] = None
if update:
self.updateSpots(dataSet)
-
+
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.
- 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."""
if dataSet is None:
dataSet = self.data
-
+
if isinstance(size, np.ndarray) or isinstance(size, list):
sizes = size
if mask is not None:
@@ -505,21 +516,21 @@ class ScatterPlotItem(GraphicsObject):
else:
self.opts['size'] = size
self._spotPixmap = None
-
+
dataSet['sourceRect'] = None
if update:
self.updateSpots(dataSet)
-
+
def setPointData(self, data, dataSet=None, mask=None):
if dataSet is None:
dataSet = self.data
-
+
if isinstance(data, np.ndarray) or isinstance(data, list):
if mask is not None:
data = data[mask]
if 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.
## (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:
@@ -527,14 +538,14 @@ class ScatterPlotItem(GraphicsObject):
dataSet['data'][i] = rec
else:
dataSet['data'] = data
-
+
def setPxMode(self, mode):
if self.opts['pxMode'] == mode:
return
-
+
self.opts['pxMode'] = mode
self.invalidate()
-
+
def updateSpots(self, dataSet=None):
if dataSet is None:
dataSet = self.data
@@ -547,9 +558,9 @@ class ScatterPlotItem(GraphicsObject):
opts = self.getSpotOpts(dataSet[mask])
sourceRect = self.fragmentAtlas.getSymbolCoords(opts)
dataSet['sourceRect'][mask] = sourceRect
-
+
self.fragmentAtlas.getAtlas() # generate atlas so source widths are available.
-
+
dataSet['width'] = np.array(list(imap(QtCore.QRectF.width, dataSet['sourceRect'])))/2
dataSet['targetRect'] = None
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['brush'][np.equal(recs['brush'], None)] = fn.mkBrush(self.opts['brush'])
return recs
-
-
-
+
+
+
def measureSpotSizes(self, dataSet):
for rec in dataSet:
## keep track of the maximum spot size and pixel size
@@ -605,8 +616,8 @@ class ScatterPlotItem(GraphicsObject):
self._maxSpotWidth = max(self._maxSpotWidth, width)
self._maxSpotPxWidth = max(self._maxSpotPxWidth, pxWidth)
self.bounds = [None, None]
-
-
+
+
def clear(self):
"""Remove all spots from the scatter plot"""
#self.clearItems()
@@ -617,23 +628,23 @@ class ScatterPlotItem(GraphicsObject):
def dataBounds(self, ax, frac=1.0, orthoRange=None):
if frac >= 1.0 and orthoRange is None and self.bounds[ax] is not None:
return self.bounds[ax]
-
+
#self.prepareGeometryChange()
if self.data is None or len(self.data) == 0:
return (None, None)
-
+
if ax == 0:
d = self.data['x']
d2 = self.data['y']
elif ax == 1:
d = self.data['y']
d2 = self.data['x']
-
+
if orthoRange is not None:
mask = (d2 >= orthoRange[0]) * (d2 <= orthoRange[1])
d = d[mask]
d2 = d2[mask]
-
+
if frac >= 1.0:
self.bounds[ax] = (np.nanmin(d) - self._maxSpotWidth*0.7072, np.nanmax(d) + self._maxSpotWidth*0.7072)
return self.bounds[ax]
@@ -656,11 +667,11 @@ class ScatterPlotItem(GraphicsObject):
if ymn is None or ymx is None:
ymn = 0
ymx = 0
-
+
px = py = 0.0
pxPad = self.pixelPadding()
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()
try:
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()
except OverflowError:
py = 0
-
+
# return bounds expanded by pixel size
px *= pxPad
py *= pxPad
@@ -688,7 +699,7 @@ class ScatterPlotItem(GraphicsObject):
def mapPointsToDevice(self, pts):
- # Map point locations to device
+ # Map point locations to device
tr = self.deviceTransform()
if tr is None:
return None
@@ -699,7 +710,7 @@ class ScatterPlotItem(GraphicsObject):
pts = fn.transformCoordinates(tr, pts)
pts -= self.data['width']
pts = np.clip(pts, -2**30, 2**30) ## prevent Qt segmentation fault.
-
+
return pts
def getViewMask(self, pts):
@@ -713,48 +724,48 @@ class ScatterPlotItem(GraphicsObject):
mask = ((pts[0] + w > viewBounds.left()) &
(pts[0] - w < viewBounds.right()) &
(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
-
-
+
+
@debug.warnOnException ## raising an exception here causes crash
def paint(self, p, *args):
#p.setPen(fn.mkPen('r'))
#p.drawRect(self.boundingRect())
-
+
if self._exportOpts is not False:
aa = self._exportOpts.get('antialias', True)
scale = self._exportOpts.get('resolutionScale', 1.0) ## exporting to image; pixel resolution may have changed
else:
aa = self.opts['antialias']
scale = 1.0
-
+
if self.opts['pxMode'] is True:
p.resetTransform()
-
+
# Map point coordinates to device
pts = np.vstack([self.data['x'], self.data['y']])
pts = self.mapPointsToDevice(pts)
if pts is None:
return
-
+
# Cull points that are outside view
viewMask = self.getViewMask(pts)
#pts = pts[:,mask]
#data = self.data[mask]
-
+
if self.opts['useCache'] and self._exportOpts is False:
# Draw symbols from pre-rendered atlas
atlas = self.fragmentAtlas.getAtlas()
-
+
# Update targetRects if necessary
updateMask = viewMask & np.equal(self.data['targetRect'], None)
if np.any(updateMask):
updatePts = pts[:,updateMask]
width = self.data[updateMask]['width']*2
self.data['targetRect'][updateMask] = list(imap(QtCore.QRectF, updatePts[0,:], updatePts[1,:], width, width))
-
+
data = self.data[viewMask]
if USE_PYSIDE or USE_PYQT5:
list(imap(p.drawPixmap, data['targetRect'], repeat(atlas), data['sourceRect']))
@@ -782,16 +793,16 @@ class ScatterPlotItem(GraphicsObject):
p2.translate(rec['x'], rec['y'])
drawSymbol(p2, *self.getSpotOpts(rec, scale))
p2.end()
-
+
p.setRenderHint(p.Antialiasing, aa)
self.picture.play(p)
-
+
def points(self):
for rec in self.data:
if rec['item'] is None:
rec['item'] = SpotItem(rec, self)
return self.data['item']
-
+
def pointsAt(self, pos):
x = pos.x()
y = pos.y()
@@ -814,7 +825,7 @@ class ScatterPlotItem(GraphicsObject):
#print "No hit:", (x, y), (sx, sy)
#print " ", (sx-s2x, sy-s2y), (sx+s2x, sy+s2y)
return pts[::-1]
-
+
def mouseClickEvent(self, ev):
if ev.button() == QtCore.Qt.LeftButton:
@@ -833,7 +844,7 @@ class ScatterPlotItem(GraphicsObject):
class SpotItem(object):
"""
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.
"""
@@ -844,34 +855,34 @@ class SpotItem(object):
#self.setParentItem(plot)
#self.setPos(QtCore.QPointF(data['x'], data['y']))
#self.updateItem()
-
+
def data(self):
"""Return the user data associated with this spot."""
return self._data['data']
-
+
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 self._data['size'] == -1:
return self._plot.opts['size']
else:
return self._data['size']
-
+
def pos(self):
return Point(self._data['x'], self._data['y'])
-
+
def viewPos(self):
return self._plot.mapToView(self.pos())
-
+
def setSize(self, size):
- """Set the size of this spot.
- If the size is set to -1, then the ScatterPlotItem's default size
+ """Set the size of this spot.
+ If the size is set to -1, then the ScatterPlotItem's default size
will be used instead."""
self._data['size'] = size
self.updateItem()
-
+
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.
"""
symbol = self._data['symbol']
@@ -883,7 +894,7 @@ class SpotItem(object):
except:
pass
return symbol
-
+
def setSymbol(self, symbol):
"""Set the symbol for this spot.
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:
pen = self._plot.opts['pen']
return fn.mkPen(pen)
-
+
def setPen(self, *args, **kargs):
"""Set the outline pen for this spot"""
pen = fn.mkPen(*args, **kargs)
self._data['pen'] = pen
self.updateItem()
-
+
def resetPen(self):
"""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.updateItem()
-
+
def brush(self):
brush = self._data['brush']
if brush is None:
brush = self._plot.opts['brush']
return fn.mkBrush(brush)
-
+
def setBrush(self, *args, **kargs):
"""Set the fill brush for this spot"""
brush = fn.mkBrush(*args, **kargs)
self._data['brush'] = brush
self.updateItem()
-
+
def resetBrush(self):
"""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.updateItem()
-
+
def setData(self, data):
"""Set the user-data associated with this spot"""
self._data['data'] = data
@@ -938,14 +949,14 @@ class SpotItem(object):
#QtGui.QGraphicsPixmapItem.__init__(self)
#self.setFlags(self.flags() | self.ItemIgnoresTransformations)
#SpotItem.__init__(self, data, plot)
-
+
#def setPixmap(self, pixmap):
#QtGui.QGraphicsPixmapItem.setPixmap(self, pixmap)
#self.setOffset(-pixmap.width()/2.+0.5, -pixmap.height()/2.)
-
+
#def updateItem(self):
#symbolOpts = (self._data['pen'], self._data['brush'], self._data['size'], self._data['symbol'])
-
+
### If all symbol options are default, use default pixmap
#if symbolOpts == (None, None, -1, ''):
#pixmap = self._plot.defaultSpotPixmap()
diff --git a/pyqtgraph/graphicsItems/TextItem.py b/pyqtgraph/graphicsItems/TextItem.py
index d3c98006..dc240929 100644
--- a/pyqtgraph/graphicsItems/TextItem.py
+++ b/pyqtgraph/graphicsItems/TextItem.py
@@ -1,13 +1,16 @@
+import numpy as np
from ..Qt import QtCore, QtGui
from ..Point import Point
-from .UIGraphicsItem import *
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).
"""
- 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:**
@@ -20,19 +23,31 @@ class TextItem(UIGraphicsItem):
sets the lower-right corner.
*border* A pen to use when drawing 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.rotateAxis = None if rotateAxis is None else Point(rotateAxis)
#self.angle = 0
- UIGraphicsItem.__init__(self)
+ GraphicsObject.__init__(self)
self.textItem = QtGui.QGraphicsTextItem()
self.textItem.setParentItem(self)
- self.lastTransform = None
+ self._lastTransform = None
self._bounds = QtCore.QRectF()
if html is None:
self.setText(text, color)
@@ -40,8 +55,7 @@ class TextItem(UIGraphicsItem):
self.setHtml(html)
self.fill = fn.mkBrush(fill)
self.border = fn.mkPen(border)
- self.rotate(angle)
- self.setFlag(self.ItemIgnoresTransformations) ## This is required to keep the text unscaled inside the viewport
+ self.setAngle(angle)
def setText(self, text, color=(200,200,200)):
"""
@@ -52,14 +66,7 @@ class TextItem(UIGraphicsItem):
color = fn.mkColor(color)
self.textItem.setDefaultTextColor(color)
self.textItem.setPlainText(text)
- self.updateText()
- #html = '%s' % (color, text)
- #self.setHtml(html)
-
- def updateAnchor(self):
- pass
- #self.resetTransform()
- #self.translate(0, 20)
+ self.updateTextPos()
def setPlainText(self, *args):
"""
@@ -68,7 +75,7 @@ class TextItem(UIGraphicsItem):
See QtGui.QGraphicsTextItem.setPlainText().
"""
self.textItem.setPlainText(*args)
- self.updateText()
+ self.updateTextPos()
def setHtml(self, *args):
"""
@@ -77,7 +84,7 @@ class TextItem(UIGraphicsItem):
See QtGui.QGraphicsTextItem.setHtml().
"""
self.textItem.setHtml(*args)
- self.updateText()
+ self.updateTextPos()
def setTextWidth(self, *args):
"""
@@ -89,7 +96,7 @@ class TextItem(UIGraphicsItem):
See QtGui.QGraphicsTextItem.setTextWidth().
"""
self.textItem.setTextWidth(*args)
- self.updateText()
+ self.updateTextPos()
def setFont(self, *args):
"""
@@ -98,50 +105,43 @@ class TextItem(UIGraphicsItem):
See QtGui.QGraphicsTextItem.setFont().
"""
self.textItem.setFont(*args)
- self.updateText()
+ self.updateTextPos()
- #def setAngle(self, angle):
- #self.angle = angle
- #self.updateText()
+ def setAngle(self, angle):
+ self.angle = angle
+ 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
- self.textItem.resetTransform()
- #self.textItem.rotate(self.angle)
- if self._exportOpts is not False and 'resolutionScale' in self._exportOpts:
- s = self._exportOpts['resolutionScale']
- self.textItem.scale(s, s)
+ ### Needed to maintain font size when rendering to image with increased resolution
+ #self.textItem.resetTransform()
+ ##self.textItem.rotate(self.angle)
+ #if self._exportOpts is not False and 'resolutionScale' in self._exportOpts:
+ #s = self._exportOpts['resolutionScale']
+ #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):
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):
- tr = p.transform()
- if self.lastTransform is not None:
- if tr != self.lastTransform:
- self.viewRangeChanged()
- self.lastTransform = tr
+ # this is not ideal because it causes another update to be scheduled.
+ # ideally, we would have a sceneTransformChanged event to react to..
+ self.updateTransform()
if self.border.style() != QtCore.Qt.NoPen or self.fill.style() != QtCore.Qt.NoBrush:
p.setPen(self.border)
@@ -149,4 +149,37 @@ class TextItem(UIGraphicsItem):
p.setRenderHint(p.Antialiasing, True)
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()
+
\ No newline at end of file
diff --git a/pyqtgraph/graphicsItems/tests/test_InfiniteLine.py b/pyqtgraph/graphicsItems/tests/test_InfiniteLine.py
new file mode 100644
index 00000000..24438864
--- /dev/null
+++ b/pyqtgraph/graphicsItems/tests/test_InfiniteLine.py
@@ -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()
diff --git a/pyqtgraph/tests/__init__.py b/pyqtgraph/tests/__init__.py
index 7a6e1173..b755c384 100644
--- a/pyqtgraph/tests/__init__.py
+++ b/pyqtgraph/tests/__init__.py
@@ -1 +1,2 @@
from .image_testing import assertImageApproved
+from .ui_testing import mousePress, mouseMove, mouseRelease, mouseDrag, mouseClick
diff --git a/pyqtgraph/tests/ui_testing.py b/pyqtgraph/tests/ui_testing.py
new file mode 100644
index 00000000..383ba4f9
--- /dev/null
+++ b/pyqtgraph/tests/ui_testing.py
@@ -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)
+