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) +