diff options
author | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2017-08-18 14:48:52 +0200 |
---|---|---|
committer | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2017-08-18 14:48:52 +0200 |
commit | f7bdc2acff3c13a6d632c28c4569690ab106eed7 (patch) | |
tree | 9d67cdb7152ee4e711379e03fe0546c7c3b97303 /silx/gui/plot/backends/BackendOpenGL.py |
Import Upstream version 0.5.0+dfsg
Diffstat (limited to 'silx/gui/plot/backends/BackendOpenGL.py')
-rw-r--r-- | silx/gui/plot/backends/BackendOpenGL.py | 1631 |
1 files changed, 1631 insertions, 0 deletions
diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py new file mode 100644 index 0000000..bc10eca --- /dev/null +++ b/silx/gui/plot/backends/BackendOpenGL.py @@ -0,0 +1,1631 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""OpenGL Plot backend.""" + +from __future__ import division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "21/03/2017" + +from collections import OrderedDict, namedtuple +from ctypes import c_void_p +import logging + +import numpy + +from .._utils import FLOAT32_MINPOS +from . import BackendBase +from .. import Colors +from ... import qt + +from ..._glutils import gl +from ... import _glutils as glu +from .glutils import ( + GLPlotCurve2D, GLPlotColormap, GLPlotRGBAImage, GLPlotFrame2D, + mat4Ortho, mat4Identity, + LEFT, RIGHT, BOTTOM, TOP, + Text2D, Shape2D) +from .glutils.PlotImageFile import saveImageToFile + +_logger = logging.getLogger(__name__) + + +# TODO idea: BackendQtMixIn class to share code between mpl and gl +# TODO check if OpenGL is available +# TODO make an off-screen mesa backend + +# Bounds ###################################################################### + +class Range(namedtuple('Range', ('min_', 'max_'))): + """Describes a 1D range""" + + @property + def range_(self): + return self.max_ - self.min_ + + @property + def center(self): + return 0.5 * (self.min_ + self.max_) + + +class Bounds(object): + """Describes plot bounds with 2 y axis""" + + def __init__(self, xMin, xMax, yMin, yMax, y2Min, y2Max): + self._xAxis = Range(xMin, xMax) + self._yAxis = Range(yMin, yMax) + self._y2Axis = Range(y2Min, y2Max) + + def __repr__(self): + return "x: %s, y: %s, y2: %s" % (repr(self._xAxis), + repr(self._yAxis), + repr(self._y2Axis)) + + @property + def xAxis(self): + return self._xAxis + + @property + def yAxis(self): + return self._yAxis + + @property + def y2Axis(self): + return self._y2Axis + + +# Content ##################################################################### + +class PlotDataContent(object): + """Manage plot data content: images and curves. + + This class is only meant to work with _OpenGLPlotCanvas. + """ + + _PRIMITIVE_TYPES = 'curve', 'image' + + def __init__(self): + self._primitives = OrderedDict() # For images and curves + + def add(self, primitive): + """Add a curve or image to the content dictionary. + + This function generates the key in the dict from the primitive. + + :param primitive: The primitive to add. + :type primitive: Instance of GLPlotCurve2D, GLPlotColormap, + GLPlotRGBAImage. + """ + if isinstance(primitive, GLPlotCurve2D): + primitiveType = 'curve' + elif isinstance(primitive, (GLPlotColormap, GLPlotRGBAImage)): + primitiveType = 'image' + else: + raise RuntimeError('Unsupported object type: %s', primitive) + + key = primitiveType, primitive.info['legend'] + self._primitives[key] = primitive + + def get(self, primitiveType, legend): + """Get the corresponding primitive of given type with given legend. + + :param str primitiveType: Type of primitive ('curve' or 'image'). + :param str legend: The legend of the primitive to retrieve. + :return: The corresponding curve or None if no such curve. + """ + assert primitiveType in self._PRIMITIVE_TYPES + return self._primitives.get((primitiveType, legend)) + + def pop(self, primitiveType, key): + """Pop the corresponding curve or return None if no such curve. + + :param str primitiveType: + :param str key: + :return: + """ + assert primitiveType in self._PRIMITIVE_TYPES + return self._primitives.pop((primitiveType, key), None) + + def zOrderedPrimitives(self, reverse=False): + """List of primitives sorted according to their z order. + + It is a stable sort (as sorted): + Original order is preserved when key is the same. + + :param bool reverse: Ascending (True, default) or descending (False). + """ + return sorted(self._primitives.values(), + key=lambda primitive: primitive.info['zOrder'], + reverse=reverse) + + def primitives(self): + """Iterator over all primitives.""" + return self._primitives.values() + + def primitiveKeys(self, primitiveType): + """Iterator over primitives of a specific type.""" + assert primitiveType in self._PRIMITIVE_TYPES + for type_, key in self._primitives.keys(): + if type_ == primitiveType: + yield key + + def getBounds(self, xPositive=False, yPositive=False): + """Bounds of the data. + + Can return strictly positive bounds (for log scale). + In this case, curves are clipped to their smaller positive value + and images with negative min are ignored. + + :param bool xPositive: True to get strictly positive range. + :param bool yPositive: True to get strictly positive range. + :return: The range of data for x, y and y2, or default (1., 100.) + if no range found for one dimension. + :rtype: Bounds + """ + xMin, yMin, y2Min = float('inf'), float('inf'), float('inf') + xMax = 0. if xPositive else -float('inf') + if yPositive: + yMax, y2Max = 0., 0. + else: + yMax, y2Max = -float('inf'), -float('inf') + + for item in self._primitives.values(): + # To support curve <= 0. and log and bypass images: + # If positive only, uses x|yMinPos if available + # and bypass other data with negative min bounds + if xPositive: + itemXMin = getattr(item, 'xMinPos', item.xMin) + if itemXMin is None or itemXMin < FLOAT32_MINPOS: + continue + else: + itemXMin = item.xMin + + if yPositive: + itemYMin = getattr(item, 'yMinPos', item.yMin) + if itemYMin is None or itemYMin < FLOAT32_MINPOS: + continue + else: + itemYMin = item.yMin + + if itemXMin < xMin: + xMin = itemXMin + if item.xMax > xMax: + xMax = item.xMax + + if item.info.get('yAxis') == 'right': + if itemYMin < y2Min: + y2Min = itemYMin + if item.yMax > y2Max: + y2Max = item.yMax + else: + if itemYMin < yMin: + yMin = itemYMin + if item.yMax > yMax: + yMax = item.yMax + + # One of the limit has not been updated, return default range + if xMin >= xMax: + xMin, xMax = 1., 100. + if yMin >= yMax: + yMin, yMax = 1., 100. + if y2Min >= y2Max: + y2Min, y2Max = 1., 100. + + return Bounds(xMin, xMax, yMin, yMax, y2Min, y2Max) + + +# shaders ##################################################################### + +_baseVertShd = """ + attribute vec2 position; + uniform mat4 matrix; + uniform bvec2 isLog; + + const float oneOverLog10 = 0.43429448190325176; + + void main(void) { + vec2 posTransformed = position; + if (isLog.x) { + posTransformed.x = oneOverLog10 * log(position.x); + } + if (isLog.y) { + posTransformed.y = oneOverLog10 * log(position.y); + } + gl_Position = matrix * vec4(posTransformed, 0.0, 1.0); + } + """ + +_baseFragShd = """ + uniform vec4 color; + uniform int hatchStep; + uniform float tickLen; + + void main(void) { + if (tickLen != 0.) { + if (mod((gl_FragCoord.x + gl_FragCoord.y) / tickLen, 2.) < 1.) { + gl_FragColor = color; + } else { + discard; + } + } else if (hatchStep == 0 || + mod(gl_FragCoord.x - gl_FragCoord.y, float(hatchStep)) == 0.) { + gl_FragColor = color; + } else { + discard; + } + } + """ + +_texVertShd = """ + attribute vec2 position; + attribute vec2 texCoords; + uniform mat4 matrix; + + varying vec2 coords; + + void main(void) { + gl_Position = matrix * vec4(position, 0.0, 1.0); + coords = texCoords; + } + """ + +_texFragShd = """ + uniform sampler2D tex; + + varying vec2 coords; + + void main(void) { + gl_FragColor = texture2D(tex, coords); + } + """ + + +# BackendOpenGL ############################################################### + +_current_context = None + + +def _getContext(): + assert _current_context is not None + return _current_context + + +class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget): + """OpenGL-based Plot backend. + + WARNINGS: + Unless stated otherwise, this API is NOT thread-safe and MUST be + called from the main thread. + When numpy arrays are passed as arguments to the API (through + :func:`addCurve` and :func:`addImage`), they are copied only if + required. + So, the caller should not modify these arrays afterwards. + """ + + _sigPostRedisplay = qt.Signal() + """Signal handling automatic asynchronous replot""" + + def __init__(self, plot, parent=None): + qt.QGLWidget.__init__(self, parent) + BackendBase.BackendBase.__init__(self, plot, parent) + + self.matScreenProj = mat4Identity() + + self._progBase = glu.Program( + _baseVertShd, _baseFragShd, attrib0='position') + self._progTex = glu.Program( + _texVertShd, _texFragShd, attrib0='position') + self._plotFBOs = {} + + self._keepDataAspectRatio = False + + self._devicePixelRatio = 1.0 + + self._crosshairCursor = None + self._mousePosInPixels = None + + self._markers = OrderedDict() + self._items = OrderedDict() + self._plotContent = PlotDataContent() # For images and curves + self._selectionAreas = OrderedDict() + self._glGarbageCollector = [] + + self._plotFrame = GLPlotFrame2D( + margins={'left': 100, 'right': 50, 'top': 50, 'bottom': 50}) + + # Make postRedisplay asynchronous using Qt signal + self._sigPostRedisplay.connect( + super(BackendOpenGL, self).postRedisplay, + qt.Qt.QueuedConnection) + + # TODO is this needed? move it Plot? + self.setGraphXLimits(0., 100.) + self.setGraphYLimits(0., 100., axis='right') + self.setGraphYLimits(0., 100., axis='left') + + self.setAutoFillBackground(False) + self.setMouseTracking(True) + + # QWidget + + _MOUSE_BTNS = {1: 'left', 2: 'right', 4: 'middle'} + + def sizeHint(self): + return qt.QSize(8 * 80, 6 * 80) # Mimic MatplotlibBackend + + def mousePressEvent(self, event): + xPixel = event.x() * self._devicePixelRatio + yPixel = event.y() * self._devicePixelRatio + btn = self._MOUSE_BTNS[event.button()] + self._plot.onMousePress(xPixel, yPixel, btn) + event.accept() + + def mouseMoveEvent(self, event): + xPixel = event.x() * self._devicePixelRatio + yPixel = event.y() * self._devicePixelRatio + + # Handle crosshair + inXPixel, inYPixel = self._mouseInPlotArea(xPixel, yPixel) + isCursorInPlot = inXPixel == xPixel and inYPixel == yPixel + + previousMousePosInPixels = self._mousePosInPixels + self._mousePosInPixels = (xPixel, yPixel) if isCursorInPlot else None + if (self._crosshairCursor is not None and + previousMousePosInPixels != self._crosshairCursor): + # Avoid replot when cursor remains outside plot area + self._plot._setDirtyPlot(overlayOnly=True) + + self._plot.onMouseMove(xPixel, yPixel) + event.accept() + + def mouseReleaseEvent(self, event): + xPixel = event.x() * self._devicePixelRatio + yPixel = event.y() * self._devicePixelRatio + + btn = self._MOUSE_BTNS[event.button()] + self._plot.onMouseRelease(xPixel, yPixel, btn) + event.accept() + + def wheelEvent(self, event): + xPixel = event.x() * self._devicePixelRatio + yPixel = event.y() * self._devicePixelRatio + + if hasattr(event, 'angleDelta'): # Qt 5 + delta = event.angleDelta().y() + else: # Qt 4 support + delta = event.delta() + angleInDegrees = delta / 8. + self._plot.onMouseWheel(xPixel, yPixel, angleInDegrees) + event.accept() + + def leaveEvent(self, _): + self._plot.onMouseLeaveWidget() + + # QGLWidget API + + @staticmethod + def _setBlendFuncGL(): + # glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + gl.glBlendFuncSeparate(gl.GL_SRC_ALPHA, + gl.GL_ONE_MINUS_SRC_ALPHA, + gl.GL_ONE, + gl.GL_ONE) + + def initializeGL(self): + gl.testGL() + + gl.glClearColor(1., 1., 1., 1.) + gl.glClearStencil(0) + + gl.glEnable(gl.GL_BLEND) + self._setBlendFuncGL() + + # For lines + gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST) + + # For points + gl.glEnable(gl.GL_VERTEX_PROGRAM_POINT_SIZE) # OpenGL 2 + gl.glEnable(gl.GL_POINT_SPRITE) # OpenGL 2 + # gl.glEnable(gl.GL_PROGRAM_POINT_SIZE) + + def _paintDirectGL(self): + self._renderPlotAreaGL() + self._plotFrame.render() + self._renderMarkersGL() + self._renderOverlayGL() + + def _paintFBOGL(self): + context = glu.getGLContext() + plotFBOTex = self._plotFBOs.get(context) + if (self._plot._getDirtyPlot() or self._plotFrame.isDirty or + plotFBOTex is None): + self._plotVertices = numpy.array(((-1., -1., 0., 0.), + (1., -1., 1., 0.), + (-1., 1., 0., 1.), + (1., 1., 1., 1.)), + dtype=numpy.float32) + if plotFBOTex is None or \ + plotFBOTex.shape[1] != self._plotFrame.size[0] or \ + plotFBOTex.shape[0] != self._plotFrame.size[1]: + if plotFBOTex is not None: + plotFBOTex.discard() + plotFBOTex = glu.FramebufferTexture( + gl.GL_RGBA, + shape=(self._plotFrame.size[1], + self._plotFrame.size[0]), + minFilter=gl.GL_NEAREST, + magFilter=gl.GL_NEAREST, + wrap=(gl.GL_CLAMP_TO_EDGE, + gl.GL_CLAMP_TO_EDGE)) + self._plotFBOs[context] = plotFBOTex + + with plotFBOTex: + gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_STENCIL_BUFFER_BIT) + self._renderPlotAreaGL() + self._plotFrame.render() + + # Render plot in screen coords + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + + self._progTex.use() + texUnit = 0 + + gl.glUniform1i(self._progTex.uniforms['tex'], texUnit) + gl.glUniformMatrix4fv(self._progTex.uniforms['matrix'], 1, gl.GL_TRUE, + mat4Identity()) + + stride = self._plotVertices.shape[-1] * self._plotVertices.itemsize + gl.glEnableVertexAttribArray(self._progTex.attributes['position']) + gl.glVertexAttribPointer(self._progTex.attributes['position'], + 2, + gl.GL_FLOAT, + gl.GL_FALSE, + stride, self._plotVertices) + + texCoordsPtr = c_void_p(self._plotVertices.ctypes.data + + 2 * self._plotVertices.itemsize) # Better way? + gl.glEnableVertexAttribArray(self._progTex.attributes['texCoords']) + gl.glVertexAttribPointer(self._progTex.attributes['texCoords'], + 2, + gl.GL_FLOAT, + gl.GL_FALSE, + stride, texCoordsPtr) + + with plotFBOTex.texture: + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(self._plotVertices)) + + self._renderMarkersGL() + self._renderOverlayGL() + + def paintGL(self): + global _current_context + _current_context = self.context() + + glu.setGLContextGetter(_getContext) + + if hasattr(self, 'windowHandle'): # Qt 5 + devicePixelRatio = self.windowHandle().devicePixelRatio() + if devicePixelRatio != self._devicePixelRatio: + self._devicePixelRatio = devicePixelRatio + self.resizeGL(int(self.width() * devicePixelRatio), + int(self.height() * devicePixelRatio)) + + # Release OpenGL resources + for item in self._glGarbageCollector: + item.discard() + self._glGarbageCollector = [] + + gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_STENCIL_BUFFER_BIT) + + # Check if window is large enough + plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] + if plotWidth <= 2 or plotHeight <= 2: + return + + # self._paintDirectGL() + self._paintFBOGL() + + glu.setGLContextGetter() + _current_context = None + + def _nonOrthoAxesLineMarkerPrimitives(self, marker, pixelOffset): + """Generates the vertices and label for a line marker. + + :param dict marker: Description of a line marker + :param int pixelOffset: Offset of text from borders in pixels + :return: Line vertices and Text label or None + :rtype: 2-tuple (2x2 numpy.array of float, Text2D) + """ + label, vertices = None, None + + xCoord, yCoord = marker['x'], marker['y'] + assert xCoord is None or yCoord is None # Specific to line markers + + # Get plot corners in data coords + plotLeft, plotTop, plotWidth, plotHeight = self.getPlotBoundsInPixels() + + corners = [(plotLeft, plotTop), + (plotLeft, plotTop + plotHeight), + (plotLeft + plotWidth, plotTop + plotHeight), + (plotLeft + plotWidth, plotTop)] + corners = numpy.array([self.pixelToData(x, y, axis='left', check=False) + for (x, y) in corners]) + + borders = { + 'right': (corners[3], corners[2]), + 'top': (corners[0], corners[3]), + 'bottom': (corners[2], corners[1]), + 'left': (corners[1], corners[0]) + } + + textLayouts = { # align, valign, offsets + 'right': (RIGHT, BOTTOM, (-1., -1.)), + 'top': (LEFT, TOP, (1., 1.)), + 'bottom': (LEFT, BOTTOM, (1., -1.)), + 'left': (LEFT, BOTTOM, (1., -1.)) + } + + if xCoord is None: # Horizontal line in data space + if marker['text'] is not None: + # Find intersection of hline with borders in data + # Order is important as it stops at first intersection + for border_name in ('right', 'top', 'bottom', 'left'): + (x0, y0), (x1, y1) = borders[border_name] + + if min(y0, y1) <= yCoord < max(y0, y1): + xIntersect = (yCoord - y0) * (x1 - x0) / (y1 - y0) + x0 + + # Add text label + pixelPos = self.dataToPixel( + xIntersect, yCoord, axis='left', check=False) + + align, valign, offsets = textLayouts[border_name] + + x = pixelPos[0] + offsets[0] * pixelOffset + y = pixelPos[1] + offsets[1] * pixelOffset + label = Text2D(marker['text'], x, y, + color=marker['color'], + bgColor=(1., 1., 1., 0.5), + align=align, valign=valign) + break # Stop at first intersection + + xMin, xMax = corners[:, 0].min(), corners[:, 0].max() + vertices = numpy.array( + ((xMin, yCoord), (xMax, yCoord)), dtype=numpy.float32) + + else: # yCoord is None: vertical line in data space + if marker['text'] is not None: + # Find intersection of hline with borders in data + # Order is important as it stops at first intersection + for border_name in ('top', 'bottom', 'right', 'left'): + (x0, y0), (x1, y1) = borders[border_name] + if min(x0, x1) <= xCoord < max(x0, x1): + yIntersect = (xCoord - x0) * (y1 - y0) / (x1 - x0) + y0 + + # Add text label + pixelPos = self.dataToPixel( + xCoord, yIntersect, axis='left', check=False) + + align, valign, offsets = textLayouts[border_name] + + x = pixelPos[0] + offsets[0] * pixelOffset + y = pixelPos[1] + offsets[1] * pixelOffset + label = Text2D(marker['text'], x, y, + color=marker['color'], + bgColor=(1., 1., 1., 0.5), + align=align, valign=valign) + break # Stop at first intersection + + yMin, yMax = corners[:, 1].min(), corners[:, 1].max() + vertices = numpy.array( + ((xCoord, yMin), (xCoord, yMax)), dtype=numpy.float32) + + return vertices, label + + def _renderMarkersGL(self): + if len(self._markers) == 0: + return + + plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] + + isXLog = self._plotFrame.xAxis.isLog + isYLog = self._plotFrame.yAxis.isLog + + # Render in plot area + gl.glScissor(self._plotFrame.margins.left, + self._plotFrame.margins.bottom, + plotWidth, plotHeight) + gl.glEnable(gl.GL_SCISSOR_TEST) + + gl.glViewport(self._plotFrame.margins.left, + self._plotFrame.margins.bottom, + plotWidth, plotHeight) + + # Prepare vertical and horizontal markers rendering + self._progBase.use() + gl.glUniformMatrix4fv(self._progBase.uniforms['matrix'], 1, gl.GL_TRUE, + self._plotFrame.transformedDataProjMat) + gl.glUniform2i(self._progBase.uniforms['isLog'], isXLog, isYLog) + gl.glUniform1i(self._progBase.uniforms['hatchStep'], 0) + gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.) + posAttrib = self._progBase.attributes['position'] + + labels = [] + pixelOffset = 3 + + for marker in self._markers.values(): + xCoord, yCoord = marker['x'], marker['y'] + + if ((isXLog and xCoord is not None and + xCoord < FLOAT32_MINPOS) or + (isYLog and yCoord is not None and + yCoord < FLOAT32_MINPOS)): + # Do not render markers with negative coords on log axis + continue + + if xCoord is None or yCoord is None: + if not self.isDefaultBaseVectors(): # Non-orthogonal axes + vertices, label = self._nonOrthoAxesLineMarkerPrimitives( + marker, pixelOffset) + if label is not None: + labels.append(label) + + else: # Orthogonal axes + pixelPos = self.dataToPixel( + xCoord, yCoord, axis='left', check=False) + + if xCoord is None: # Horizontal line in data space + if marker['text'] is not None: + x = self._plotFrame.size[0] - \ + self._plotFrame.margins.right - pixelOffset + y = pixelPos[1] - pixelOffset + label = Text2D(marker['text'], x, y, + color=marker['color'], + bgColor=(1., 1., 1., 0.5), + align=RIGHT, valign=BOTTOM) + labels.append(label) + + xMin, xMax = self._plotFrame.dataRanges.x + vertices = numpy.array(((xMin, yCoord), + (xMax, yCoord)), + dtype=numpy.float32) + + else: # yCoord is None: vertical line in data space + if marker['text'] is not None: + x = pixelPos[0] + pixelOffset + y = self._plotFrame.margins.top + pixelOffset + label = Text2D(marker['text'], x, y, + color=marker['color'], + bgColor=(1., 1., 1., 0.5), + align=LEFT, valign=TOP) + labels.append(label) + + yMin, yMax = self._plotFrame.dataRanges.y + vertices = numpy.array(((xCoord, yMin), + (xCoord, yMax)), + dtype=numpy.float32) + + self._progBase.use() + + gl.glUniform4f(self._progBase.uniforms['color'], + *marker['color']) + + gl.glEnableVertexAttribArray(posAttrib) + gl.glVertexAttribPointer(posAttrib, + 2, + gl.GL_FLOAT, + gl.GL_FALSE, + 0, vertices) + gl.glLineWidth(1) + gl.glDrawArrays(gl.GL_LINES, 0, len(vertices)) + + else: + pixelPos = self.dataToPixel( + xCoord, yCoord, axis='left', check=True) + if pixelPos is None: + # Do not render markers outside visible plot area + continue + + if marker['text'] is not None: + x = pixelPos[0] + pixelOffset + y = pixelPos[1] + pixelOffset + label = Text2D(marker['text'], x, y, + color=marker['color'], + bgColor=(1., 1., 1., 0.5), + align=LEFT, valign=TOP) + labels.append(label) + + # For now simple implementation: using a curve for each marker + # Should pack all markers to a single set of points + markerCurve = GLPlotCurve2D( + numpy.array((xCoord,), dtype=numpy.float32), + numpy.array((yCoord,), dtype=numpy.float32), + marker=marker['symbol'], + markerColor=marker['color'], + markerSize=11) + markerCurve.render(self._plotFrame.transformedDataProjMat, + isXLog, isYLog) + markerCurve.discard() + + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + + # Render marker labels + for label in labels: + label.render(self.matScreenProj) + + gl.glDisable(gl.GL_SCISSOR_TEST) + + def _renderOverlayGL(self): + # Render selection area and crosshair cursor + if self._selectionAreas or self._crosshairCursor is not None: + plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] + + # Scissor to plot area + gl.glScissor(self._plotFrame.margins.left, + self._plotFrame.margins.bottom, + plotWidth, plotHeight) + gl.glEnable(gl.GL_SCISSOR_TEST) + + self._progBase.use() + gl.glUniform2i(self._progBase.uniforms['isLog'], + self._plotFrame.xAxis.isLog, + self._plotFrame.yAxis.isLog) + gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.) + posAttrib = self._progBase.attributes['position'] + matrixUnif = self._progBase.uniforms['matrix'] + colorUnif = self._progBase.uniforms['color'] + hatchStepUnif = self._progBase.uniforms['hatchStep'] + + # Render selection area in plot area + if self._selectionAreas: + gl.glViewport(self._plotFrame.margins.left, + self._plotFrame.margins.bottom, + plotWidth, plotHeight) + + gl.glUniformMatrix4fv(matrixUnif, 1, gl.GL_TRUE, + self._plotFrame.transformedDataProjMat) + + for shape in self._selectionAreas.values(): + if shape.isVideoInverted: + gl.glBlendFunc(gl.GL_ONE_MINUS_DST_COLOR, gl.GL_ZERO) + + shape.render(posAttrib, colorUnif, hatchStepUnif) + + if shape.isVideoInverted: + self._setBlendFuncGL() + + # Render crosshair cursor is screen frame but with scissor + if (self._crosshairCursor is not None and + self._mousePosInPixels is not None): + gl.glViewport( + 0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + + gl.glUniformMatrix4fv(matrixUnif, 1, gl.GL_TRUE, + self.matScreenProj) + + color, lineWidth = self._crosshairCursor + gl.glUniform4f(colorUnif, *color) + gl.glUniform1i(hatchStepUnif, 0) + + xPixel, yPixel = self._mousePosInPixels + xPixel, yPixel = xPixel + 0.5, yPixel + 0.5 + vertices = numpy.array(((0., yPixel), + (self._plotFrame.size[0], yPixel), + (xPixel, 0.), + (xPixel, self._plotFrame.size[1])), + dtype=numpy.float32) + + gl.glEnableVertexAttribArray(posAttrib) + gl.glVertexAttribPointer(posAttrib, + 2, + gl.GL_FLOAT, + gl.GL_FALSE, + 0, vertices) + gl.glLineWidth(lineWidth) + gl.glDrawArrays(gl.GL_LINES, 0, len(vertices)) + + gl.glDisable(gl.GL_SCISSOR_TEST) + + def _renderPlotAreaGL(self): + plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] + + self._plotFrame.renderGrid() + + gl.glScissor(self._plotFrame.margins.left, + self._plotFrame.margins.bottom, + plotWidth, plotHeight) + gl.glEnable(gl.GL_SCISSOR_TEST) + + # Matrix + trBounds = self._plotFrame.transformedDataRanges + if trBounds.x[0] == trBounds.x[1] or \ + trBounds.y[0] == trBounds.y[1]: + return + + isXLog = self._plotFrame.xAxis.isLog + isYLog = self._plotFrame.yAxis.isLog + + gl.glViewport(self._plotFrame.margins.left, + self._plotFrame.margins.bottom, + plotWidth, plotHeight) + + # Render images and curves + # sorted is stable: original order is preserved when key is the same + for item in self._plotContent.zOrderedPrimitives(): + if item.info.get('yAxis') == 'right': + item.render(self._plotFrame.transformedDataY2ProjMat, + isXLog, isYLog) + else: + item.render(self._plotFrame.transformedDataProjMat, + isXLog, isYLog) + + # Render Items + self._progBase.use() + gl.glUniformMatrix4fv(self._progBase.uniforms['matrix'], 1, gl.GL_TRUE, + self._plotFrame.transformedDataProjMat) + gl.glUniform2i(self._progBase.uniforms['isLog'], + self._plotFrame.xAxis.isLog, + self._plotFrame.yAxis.isLog) + gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.) + + for item in self._items.values(): + shape2D = item.get('_shape2D') + if shape2D is None: + shape2D = Shape2D(tuple(zip(item['x'], item['y'])), + fill=item['fill'], + fillColor=item['color'], + stroke=True, + strokeColor=item['color']) + item['_shape2D'] = shape2D + + if ((isXLog and shape2D.xMin < FLOAT32_MINPOS) or + (isYLog and shape2D.yMin < FLOAT32_MINPOS)): + # Ignore items <= 0. on log axes + continue + + posAttrib = self._progBase.attributes['position'] + colorUnif = self._progBase.uniforms['color'] + hatchStepUnif = self._progBase.uniforms['hatchStep'] + shape2D.render(posAttrib, colorUnif, hatchStepUnif) + + gl.glDisable(gl.GL_SCISSOR_TEST) + + def resizeGL(self, width, height): + if width == 0 or height == 0: # Do not resize + return + self._plotFrame.size = width, height + + self.matScreenProj = mat4Ortho(0, self._plotFrame.size[0], + self._plotFrame.size[1], 0, + 1, -1) + + (xMin, xMax), (yMin, yMax), (y2Min, y2Max) = \ + self._plotFrame.dataRanges + self.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max) + + # Add methods + + def addCurve(self, x, y, legend, + color, symbol, linewidth, linestyle, + yaxis, + xerror, yerror, z, selectable, + fill, alpha, symbolsize): + for parameter in (x, y, legend, color, symbol, linewidth, linestyle, + yaxis, z, selectable, fill, symbolsize): + assert parameter is not None + assert yaxis in ('left', 'right') + + x = numpy.array(x, dtype=numpy.float32, copy=False, order='C') + y = numpy.array(y, dtype=numpy.float32, copy=False, order='C') + if xerror is not None: + xerror = numpy.array( + xerror, dtype=numpy.float32, copy=False, order='C') + if yerror is not None: + yerror = numpy.array( + yerror, dtype=numpy.float32, copy=False, order='C') + + # TODO check and improve this + if (len(color) == 4 and + type(color[3]) in [type(1), numpy.uint8, numpy.int8]): + color = numpy.array(color, dtype=numpy.float32) / 255. + + if isinstance(color, numpy.ndarray) and color.ndim == 2: + colorArray = color + color = None + else: + colorArray = None + color = Colors.rgba(color) + + if alpha < 1.: # Apply image transparency + if colorArray is not None and colorArray.shape[1] == 4: + # multiply alpha channel + colorArray[:, 3] = colorArray[:, 3] * alpha + if color is not None: + color = color[0], color[1], color[2], color[3] * alpha + + behaviors = set() + if selectable: + behaviors.add('selectable') + + curve = GLPlotCurve2D(x, y, colorArray, + xError=xerror, + yError=yerror, + lineStyle=linestyle, + lineColor=color, + lineWidth=linewidth, + marker=symbol, + markerColor=color, + markerSize=symbolsize, + fillColor=color if fill else None) + curve.info = { + 'legend': legend, + 'zOrder': z, + 'behaviors': behaviors, + 'yAxis': 'left' if yaxis is None else yaxis, + } + + if yaxis == "right": + self._plotFrame.isY2Axis = True + + self._plotContent.add(curve) + + return legend, 'curve' + + def addImage(self, data, legend, + origin, scale, z, + selectable, draggable, + colormap, alpha): + for parameter in (data, legend, origin, scale, z, + selectable, draggable): + assert parameter is not None + + behaviors = set() + if selectable: + behaviors.add('selectable') + if draggable: + behaviors.add('draggable') + + if data.ndim == 2: + # Ensure array is contiguous and eventually convert its type + if data.dtype in (numpy.float32, numpy.uint8, numpy.uint16): + data = numpy.array(data, copy=False, order='C') + else: + _logger.info( + 'addImage: Convert %s data to float32', str(data.dtype)) + data = numpy.array(data, dtype=numpy.float32, order='C') + + colormapIsLog = colormap['normalization'].startswith('log') + + if colormap['autoscale']: + cmapRange = None + else: + cmapRange = colormap['vmin'], colormap['vmax'] + assert cmapRange[0] <= cmapRange[1] + + # Retrieve colormap LUT from name and color array + colormapLut = Colors.applyColormapToData( + numpy.arange(256, dtype=numpy.uint8), + name=colormap['name'], + normalization='linear', + autoscale=False, + vmin=0, + vmax=255, + colors=colormap.get('colors')) + + image = GLPlotColormap(data, + origin, + scale, + colormapLut, + colormapIsLog, + cmapRange, + alpha) + image.info = { + 'legend': legend, + 'zOrder': z, + 'behaviors': behaviors + } + self._plotContent.add(image) + + elif len(data.shape) == 3: + # For RGB, RGBA data + assert data.shape[2] in (3, 4) + assert data.dtype in (numpy.float32, numpy.uint8) + + image = GLPlotRGBAImage(data, origin, scale, alpha) + + image.info = { + 'legend': legend, + 'zOrder': z, + 'behaviors': behaviors + } + + if self._plotFrame.xAxis.isLog and image.xMin <= 0.: + raise RuntimeError( + 'Cannot add image with X <= 0 with X axis log scale') + if self._plotFrame.yAxis.isLog and image.yMin <= 0.: + raise RuntimeError( + 'Cannot add image with Y <= 0 with Y axis log scale') + + self._plotContent.add(image) + + else: + raise RuntimeError("Unsupported data shape {0}".format(data.shape)) + + return legend, 'image' + + def addItem(self, x, y, legend, shape, color, fill, overlay, z): + # TODO handle overlay + if shape not in ('polygon', 'rectangle', 'line', 'vline', 'hline'): + raise NotImplementedError("Unsupported shape {0}".format(shape)) + + x = numpy.array(x, copy=False) + y = numpy.array(y, copy=False) + + if shape == 'rectangle': + xMin, xMax = x + x = numpy.array((xMin, xMin, xMax, xMax)) + yMin, yMax = y + y = numpy.array((yMin, yMax, yMax, yMin)) + + # TODO is this needed? + if self._plotFrame.xAxis.isLog and x.min() <= 0.: + raise RuntimeError( + 'Cannot add item with X <= 0 with X axis log scale') + if self._plotFrame.yAxis.isLog and y.min() <= 0.: + raise RuntimeError( + 'Cannot add item with Y <= 0 with Y axis log scale') + + self._items[legend] = { + 'shape': shape, + 'color': Colors.rgba(color), + 'fill': 'hatch' if fill else None, + 'x': x, + 'y': y + } + + return legend, 'item' + + def addMarker(self, x, y, legend, text, color, + selectable, draggable, + symbol, constraint, overlay): + # TODO handle overlay + + if symbol is None: + symbol = '+' + + behaviors = set() + if selectable: + behaviors.add('selectable') + if draggable: + behaviors.add('draggable') + + # Apply constraint to provided position + isConstraint = (draggable and constraint is not None and + x is not None and y is not None) + if isConstraint: + x, y = constraint(x, y) + + if x is not None and self._plotFrame.xAxis.isLog and x <= 0.: + raise RuntimeError( + 'Cannot add marker with X <= 0 with X axis log scale') + if y is not None and self._plotFrame.yAxis.isLog and y <= 0.: + raise RuntimeError( + 'Cannot add marker with Y <= 0 with Y axis log scale') + + self._markers[legend] = { + 'x': x, + 'y': y, + 'legend': legend, + 'text': text, + 'color': Colors.rgba(color), + 'behaviors': behaviors, + 'constraint': constraint if isConstraint else None, + 'symbol': symbol, + } + + return legend, 'marker' + + # Remove methods + + def remove(self, item): + legend, kind = item + + if kind == 'curve': + curve = self._plotContent.pop('curve', legend) + if curve is not None: + # Check if some curves remains on the right Y axis + y2AxisItems = (item for item in self._plotContent.primitives() + if item.info.get('yAxis', 'left') == 'right') + self._plotFrame.isY2Axis = next(y2AxisItems, None) is not None + + self._glGarbageCollector.append(curve) + + elif kind == 'image': + image = self._plotContent.pop('image', legend) + if image is not None: + self._glGarbageCollector.append(image) + + elif kind == 'marker': + self._markers.pop(legend, False) + + elif kind == 'item': + self._items.pop(legend, False) + + else: + _logger.error('Unsupported kind: %s', str(kind)) + + # Interaction methods + + _QT_CURSORS = { + None: qt.Qt.ArrowCursor, + BackendBase.CURSOR_DEFAULT: qt.Qt.ArrowCursor, + BackendBase.CURSOR_POINTING: qt.Qt.PointingHandCursor, + BackendBase.CURSOR_SIZE_HOR: qt.Qt.SizeHorCursor, + BackendBase.CURSOR_SIZE_VER: qt.Qt.SizeVerCursor, + BackendBase.CURSOR_SIZE_ALL: qt.Qt.SizeAllCursor, + } + + def setGraphCursorShape(self, cursor): + cursor = self._QT_CURSORS[cursor] + + super(BackendOpenGL, self).setCursor(qt.QCursor(cursor)) + + def setGraphCursor(self, flag, color, linewidth, linestyle): + if linestyle is not '-': + _logger.warning( + "BackendOpenGL.setGraphCursor linestyle parameter ignored") + + if flag: + color = Colors.rgba(color) + crosshairCursor = color, linewidth + else: + crosshairCursor = None + + if crosshairCursor != self._crosshairCursor: + self._crosshairCursor = crosshairCursor + + _PICK_OFFSET = 3 # Offset in pixel used for picking + + def _mouseInPlotArea(self, x, y): + xPlot = numpy.clip( + x, self._plotFrame.margins.left, + self._plotFrame.size[0] - self._plotFrame.margins.right - 1) + yPlot = numpy.clip( + y, self._plotFrame.margins.top, + self._plotFrame.size[1] - self._plotFrame.margins.bottom - 1) + return xPlot, yPlot + + def pickItems(self, x, y): + picked = [] + + dataPos = self.pixelToData(x, y, axis='left', check=True) + if dataPos is not None: + # Pick markers + for marker in reversed(list(self._markers.values())): + pixelPos = self.dataToPixel( + marker['x'], marker['y'], axis='left', check=False) + if pixelPos is None: # negative coord on a log axis + continue + + if marker['x'] is None: # Horizontal line + pt1 = self.pixelToData( + x, y - self._PICK_OFFSET, axis='left', check=False) + pt2 = self.pixelToData( + x, y + self._PICK_OFFSET, axis='left', check=False) + isPicked = (min(pt1[1], pt2[1]) <= marker['y'] <= + max(pt1[1], pt2[1])) + + elif marker['y'] is None: # Vertical line + pt1 = self.pixelToData( + x - self._PICK_OFFSET, y, axis='left', check=False) + pt2 = self.pixelToData( + x + self._PICK_OFFSET, y, axis='left', check=False) + isPicked = (min(pt1[0], pt2[0]) <= marker['x'] <= + max(pt1[0], pt2[0])) + + else: + isPicked = ( + numpy.fabs(x - pixelPos[0]) <= self._PICK_OFFSET and + numpy.fabs(y - pixelPos[1]) <= self._PICK_OFFSET) + + if isPicked: + picked.append(dict(kind='marker', + legend=marker['legend'])) + + # Pick image and curves + for item in self._plotContent.zOrderedPrimitives(reverse=True): + if isinstance(item, (GLPlotColormap, GLPlotRGBAImage)): + pickedPos = item.pick(*dataPos) + if pickedPos is not None: + picked.append(dict(kind='image', + legend=item.info['legend'])) + + elif isinstance(item, GLPlotCurve2D): + offset = self._PICK_OFFSET + if item.marker is not None: + offset = max(item.markerSize / 2., offset) + if item.lineStyle is not None: + offset = max(item.lineWidth / 2., offset) + + yAxis = item.info['yAxis'] + + inAreaPos = self._mouseInPlotArea(x - offset, y - offset) + dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1], + axis=yAxis, check=True) + if dataPos is None: + continue + xPick0, yPick0 = dataPos + + inAreaPos = self._mouseInPlotArea(x + offset, y + offset) + dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1], + axis=yAxis, check=True) + if dataPos is None: + continue + xPick1, yPick1 = dataPos + + if xPick0 < xPick1: + xPickMin, xPickMax = xPick0, xPick1 + else: + xPickMin, xPickMax = xPick1, xPick0 + + if yPick0 < yPick1: + yPickMin, yPickMax = yPick0, yPick1 + else: + yPickMin, yPickMax = yPick1, yPick0 + + pickedIndices = item.pick(xPickMin, yPickMin, + xPickMax, yPickMax) + if pickedIndices: + picked.append(dict(kind='curve', + legend=item.info['legend'], + xdata=item.xData[pickedIndices], + ydata=item.yData[pickedIndices])) + + return picked + + # Update curve + + def setCurveColor(self, curve, color): + pass # TODO + + # Misc. + + def getWidgetHandle(self): + return self + + def postRedisplay(self): + self._sigPostRedisplay.emit() + + def replot(self): + self.update() # async redraw + # self.repaint() # immediate redraw + + def saveGraph(self, fileName, fileFormat, dpi): + if dpi is not None: + _logger.warning("saveGraph ignores dpi parameter") + + if fileFormat not in ['png', 'ppm', 'svg', 'tiff']: + raise NotImplementedError('Unsupported format: %s' % fileFormat) + + self.makeCurrent() + + data = numpy.empty( + (self._plotFrame.size[1], self._plotFrame.size[0], 3), + dtype=numpy.uint8, order='C') + + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0) + gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) + gl.glReadPixels(0, 0, self._plotFrame.size[0], self._plotFrame.size[1], + gl.GL_RGB, gl.GL_UNSIGNED_BYTE, data) + + # glReadPixels gives bottom to top, + # while images are stored as top to bottom + data = numpy.flipud(data) + + # fileName is either a file-like object or a str + saveImageToFile(data, fileName, fileFormat) + + # Graph labels + + def setGraphTitle(self, title): + self._plotFrame.title = title + + def setGraphXLabel(self, label): + self._plotFrame.xAxis.title = label + + def setGraphYLabel(self, label, axis): + if axis == 'left': + self._plotFrame.yAxis.title = label + else: # right axis + if label: + _logger.warning('Right axis label not implemented') + + # Non orthogonal axes + + def setBaseVectors(self, x=(1., 0.), y=(0., 1.)): + """Set base vectors. + + Useful for non-orthogonal axes. + If an axis is in log scale, skew is applied to log transformed values. + + Base vector does not work well with log axes, to investi + """ + if x != (1., 0.) and y != (0., 1.): + if self._plotFrame.xAxis.isLog: + _logger.warning("setBaseVectors disables X axis logarithmic.") + self.setXAxisLogarithmic(False) + if self._plotFrame.yAxis.isLog: + _logger.warning("setBaseVectors disables Y axis logarithmic.") + self.setYAxisLogarithmic(False) + + if self.isKeepDataAspectRatio(): + _logger.warning("setBaseVectors disables keepDataAspectRatio.") + self.keepDataAspectRatio(False) + + self._plotFrame.baseVectors = x, y + + def getBaseVectors(self): + return self._plotFrame.baseVectors + + def isDefaultBaseVectors(self): + return self._plotFrame.baseVectors == \ + self._plotFrame.DEFAULT_BASE_VECTORS + + # Graph limits + + def _setDataRanges(self, xlim=None, ylim=None, y2lim=None): + """Set the visible range of data in the plot frame. + + This clips the ranges to possible values (takes care of float32 + range + positive range for log). + This also takes care of non-orthogonal axes. + + This should be moved to PlotFrame. + """ + # Update axes range with a clipped range if too wide + self._plotFrame.setDataRanges(xlim, ylim, y2lim) + + if not self.isDefaultBaseVectors(): + # Update axes range with axes bounds in data coords + plotLeft, plotTop, plotWidth, plotHeight = \ + self.getPlotBoundsInPixels() + + self._plotFrame.xAxis.dataRange = sorted([ + self.pixelToData(x, y, axis='left', check=False)[0] + for (x, y) in ((plotLeft, plotTop + plotHeight), + (plotLeft + plotWidth, plotTop + plotHeight))]) + + self._plotFrame.yAxis.dataRange = sorted([ + self.pixelToData(x, y, axis='left', check=False)[1] + for (x, y) in ((plotLeft, plotTop + plotHeight), + (plotLeft, plotTop))]) + + self._plotFrame.y2Axis.dataRange = sorted([ + self.pixelToData(x, y, axis='right', check=False)[1] + for (x, y) in ((plotLeft + plotWidth, plotTop + plotHeight), + (plotLeft + plotWidth, plotTop))]) + + def _ensureAspectRatio(self, keepDim=None): + """Update plot bounds in order to keep aspect ratio. + + Warning: keepDim on right Y axis is not implemented ! + + :param str keepDim: The dimension to maintain: 'x', 'y' or None. + If None (the default), the dimension with the largest range. + """ + plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] + if plotWidth <= 2 or plotHeight <= 2: + return + + if keepDim is None: + dataBounds = self._plotContent.getBounds( + self._plotFrame.xAxis.isLog, self._plotFrame.yAxis.isLog) + if dataBounds.yAxis.range_ != 0.: + dataRatio = dataBounds.xAxis.range_ + dataRatio /= float(dataBounds.yAxis.range_) + + plotRatio = plotWidth / float(plotHeight) # Test != 0 before + + keepDim = 'x' if dataRatio > plotRatio else 'y' + else: # Limit case + keepDim = 'x' + + (xMin, xMax), (yMin, yMax), (y2Min, y2Max) = \ + self._plotFrame.dataRanges + if keepDim == 'y': + dataW = (yMax - yMin) * plotWidth / float(plotHeight) + xCenter = 0.5 * (xMin + xMax) + xMin = xCenter - 0.5 * dataW + xMax = xCenter + 0.5 * dataW + elif keepDim == 'x': + dataH = (xMax - xMin) * plotHeight / float(plotWidth) + yCenter = 0.5 * (yMin + yMax) + yMin = yCenter - 0.5 * dataH + yMax = yCenter + 0.5 * dataH + y2Center = 0.5 * (y2Min + y2Max) + y2Min = y2Center - 0.5 * dataH + y2Max = y2Center + 0.5 * dataH + else: + raise RuntimeError('Unsupported dimension to keep: %s' % keepDim) + + # Update plot frame bounds + self._setDataRanges(xlim=(xMin, xMax), + ylim=(yMin, yMax), + y2lim=(y2Min, y2Max)) + + def _setPlotBounds(self, xRange=None, yRange=None, y2Range=None, + keepDim=None): + # Update axes range with a clipped range if too wide + self._setDataRanges(xlim=xRange, + ylim=yRange, + y2lim=y2Range) + + # Keep data aspect ratio + if self.isKeepDataAspectRatio(): + self._ensureAspectRatio(keepDim) + + def setLimits(self, xmin, xmax, ymin, ymax, y2min=None, y2max=None): + assert xmin < xmax + assert ymin < ymax + + if y2min is None or y2max is None: + y2Range = None + else: + assert y2min < y2max + y2Range = y2min, y2max + self._setPlotBounds((xmin, xmax), (ymin, ymax), y2Range) + + def getGraphXLimits(self): + return self._plotFrame.dataRanges.x + + def setGraphXLimits(self, xmin, xmax): + assert xmin < xmax + self._setPlotBounds(xRange=(xmin, xmax), keepDim='x') + + def getGraphYLimits(self, axis): + assert axis in ("left", "right") + if axis == "left": + return self._plotFrame.dataRanges.y + else: + return self._plotFrame.dataRanges.y2 + + def setGraphYLimits(self, ymin, ymax, axis): + assert ymin < ymax + assert axis in ("left", "right") + + if axis == "left": + self._setPlotBounds(yRange=(ymin, ymax), keepDim='y') + else: + self._setPlotBounds(y2Range=(ymin, ymax), keepDim='y') + + # Graph axes + + def setXAxisLogarithmic(self, flag): + if flag != self._plotFrame.xAxis.isLog: + if flag and self._keepDataAspectRatio: + _logger.warning( + "KeepDataAspectRatio is ignored with log axes") + + if flag and not self.isDefaultBaseVectors(): + _logger.warning( + "setXAxisLogarithmic ignored because baseVectors are set") + return + + self._plotFrame.xAxis.isLog = flag + + def setYAxisLogarithmic(self, flag): + if (flag != self._plotFrame.yAxis.isLog or + flag != self._plotFrame.y2Axis.isLog): + if flag and self._keepDataAspectRatio: + _logger.warning( + "KeepDataAspectRatio is ignored with log axes") + + if flag and not self.isDefaultBaseVectors(): + _logger.warning( + "setYAxisLogarithmic ignored because baseVectors are set") + return + + self._plotFrame.yAxis.isLog = flag + self._plotFrame.y2Axis.isLog = flag + + def setYAxisInverted(self, flag): + if flag != self._plotFrame.isYAxisInverted: + self._plotFrame.isYAxisInverted = flag + + def isYAxisInverted(self): + return self._plotFrame.isYAxisInverted + + def isKeepDataAspectRatio(self): + if self._plotFrame.xAxis.isLog or self._plotFrame.yAxis.isLog: + return False + else: + return self._keepDataAspectRatio + + def setKeepDataAspectRatio(self, flag): + if flag and (self._plotFrame.xAxis.isLog or + self._plotFrame.yAxis.isLog): + _logger.warning("KeepDataAspectRatio is ignored with log axes") + if flag and not self.isDefaultBaseVectors(): + _logger.warning( + "keepDataAspectRatio ignored because baseVectors are set") + + self._keepDataAspectRatio = flag + + def setGraphGrid(self, which): + assert which in (None, 'major', 'both') + self._plotFrame.grid = which is not None # TODO True grid support + + # Data <-> Pixel coordinates conversion + + def dataToPixel(self, x, y, axis, check=False): + assert axis in ('left', 'right') + + if x is None or y is None: + dataBounds = self._plotContent.getBounds( + self._plotFrame.xAxis.isLog, self._plotFrame.yAxis.isLog) + + if x is None: + x = dataBounds.xAxis.center + + if y is None: + if axis == 'left': + y = dataBounds.yAxis.center + else: + y = dataBounds.y2Axis.center + + result = self._plotFrame.dataToPixel(x, y, axis) + + if check and result is not None: + xPixel, yPixel = result + width, height = self._plotFrame.size + if (xPixel < self._plotFrame.margins.left or + xPixel > (width - self._plotFrame.margins.right) or + yPixel < self._plotFrame.margins.top or + yPixel > height - self._plotFrame.margins.bottom): + return None # (x, y) is out of plot area + + return result + + def pixelToData(self, x, y, axis, check): + assert axis in ("left", "right") + + if x is None: + x = self._plotFrame.size[0] / 2. + if y is None: + y = self._plotFrame.size[1] / 2. + + if check and (x < self._plotFrame.margins.left or + x > (self._plotFrame.size[0] - + self._plotFrame.margins.right) or + y < self._plotFrame.margins.top or + y > (self._plotFrame.size[1] - + self._plotFrame.margins.bottom)): + return None # (x, y) is out of plot area + + return self._plotFrame.pixelToData(x, y, axis) + + def getPlotBoundsInPixels(self): + return self._plotFrame.plotOrigin + self._plotFrame.plotSize |