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/plottingItems.py b/examples/plottingItems.py new file mode 100644 index 00000000..d90d81ab --- /dev/null +++ b/examples/plottingItems.py @@ -0,0 +1,40 @@ +# -*- 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) + +p1 = win.addPlot(title="Plot Items example", y=np.random.normal(size=100, scale=10), pen=0.5) +p1.setYRange(-40, 40) +inf1 = pg.InfiniteLine(movable=True, angle=90, text='x={value:0.2f}', + textOpts={'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), text='y={value:0.2f}mm', + textOpts={'color': (200,0,0), 'movable': True, 'fill': (0, 0, 200, 100)}) +inf3 = pg.InfiniteLine(movable=True, angle=45, pen='g', text='diagonal', textOpts={'rotateAxis': [1, 0], 'fill': (0, 200, 0, 100), 'movable': True}) +inf1.setPos([2,2]) +#inf1.setTextLocation(position=0.75) +#inf2.setTextLocation(shift=0.8) +p1.addItem(inf1) +p1.addItem(inf2) +p1.addItem(inf3) + +lr = pg.LinearRegionItem(values=[70, 80]) +p1.addItem(lr) + +## 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/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/pyqtgraph/graphicsItems/InfiniteLine.py b/pyqtgraph/graphicsItems/InfiniteLine.py index 6984a7a4..22c9a281 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'] + + 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, text=None, textOpts=None, name=None): """ =============== ================================================================== **Arguments:** @@ -37,13 +42,22 @@ 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. + text 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. + textOpts A dict of keyword arguments to use when constructing the + text label. See :class:`InfLineLabel`. + name Name of the item =============== ================================================================== """ - + GraphicsObject.__init__(self) - + if bounds is None: ## allowed value boundaries for orthogonal lines self.maxRange = [None, None] else: @@ -53,66 +67,81 @@ class InfiniteLine(GraphicsObject): self.mouseHovering = False self.p = [0, 0] self.setAngle(angle) + + if text is not None: + textOpts = {} if textOpts is None else textOpts + self.textItem = InfLineLabel(self, text=text, **textOpts) + self.textItem.setParentItem(self) + + self.anchorLeft = (1., 0.5) + self.anchorRight = (0., 0.5) + self.anchorUp = (0.5, 1.) + self.anchorDown = (0.5, 0.) + 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 self._boundingRect = None self._line = None - + + self._name = name + 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): @@ -124,10 +153,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]) @@ -136,28 +165,28 @@ 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: # Invalidate bounding rect and line self._boundingRect = None self._line = None 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() @@ -165,10 +194,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) @@ -182,22 +211,16 @@ class InfiniteLine(GraphicsObject): #print "ignore", change #return GraphicsObject.itemChange(self, change, val) - def viewTransformChanged(self): - self._boundingRect = None + def _invalidateCache(self): self._line = None - GraphicsObject.viewTransformChanged(self) - - def viewChanged(self, view, oldView): self._boundingRect = None - self._line = None - GraphicsObject.viewChanged(self, view, oldView) def boundingRect(self): if self._boundingRect is None: #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 @@ -206,13 +229,13 @@ class InfiniteLine(GraphicsObject): br.setTop(w) br = br.normalized() self._boundingRect = br - self._line = QtCore.QLineF(br.right(), 0, br.left(), 0) + self._line = QtCore.QLineF(br.right(), 0.0, br.left(), 0.0) return self._boundingRect - + def paint(self, p, *args): p.setPen(self.currentPen) 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 @@ -226,16 +249,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() @@ -260,3 +283,122 @@ 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. + """ + def __init__(self, line, text="", movable=False, position=0.5, **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) + TextItem.__init__(self, **kwds) + self.valueChanged() + + def valueChanged(self): + if not self.isVisible(): + return + value = self.line.value() + self.setText(self.format.format(value=value)) + self.updatePosition() + + def updatePosition(self): + # update text position to relative view location along line + + view = self.getViewBox() + if not self.isVisible() or not isinstance(view, ViewBox): + # not in a viewbox, skip update + return + + 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. + 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() + if len(paths) > 0: + l = list(paths[0]) + pt1 = self.line.mapFromItem(view, l[0]) + pt2 = self.line.mapFromItem(view, l[1]) + + pt = pt2 * self.orthoPos + pt1 * (1-self.orthoPos) + + self.setPos(pt) + + def setVisible(self, v): + TextItem.setVisible(self, v) + if v: + self.updateText() + self.updatePosition() + + def setMovable(self, m): + self.movable = m + self.setAcceptHoverEvents(m) + + 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 + view = self.getViewBox() + tr = view.childGroup.itemTransform(self.line)[0] + vr = tr.mapRect(view.viewRect()) + pos = self.mapToParent(pos) + return (pos.x() - vr.left()) / vr.width() diff --git a/pyqtgraph/graphicsItems/TextItem.py b/pyqtgraph/graphicsItems/TextItem.py index d3c98006..220d5859 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=(1, 0)): """ ============== ================================================================================= **Arguments:** @@ -20,19 +23,22 @@ 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. ============== ================================================================================= """ - - ## 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 +46,8 @@ 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) + #self.textItem.setFlag(self.ItemIgnoresTransformations) ## This is required to keep the text unscaled inside the viewport def setText(self, text, color=(200,200,200)): """ @@ -100,48 +106,37 @@ class TextItem(UIGraphicsItem): self.textItem.setFont(*args) self.updateText() - #def setAngle(self, angle): - #self.angle = angle - #self.updateText() - + def setAngle(self, angle): + self.angle = angle + self.updateTransform() def updateText(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 +144,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.updateText() + \ No newline at end of file