diff options
Diffstat (limited to 'silx/gui/plot/backends')
-rwxr-xr-x[-rw-r--r--] | silx/gui/plot/backends/BackendBase.py | 87 | ||||
-rwxr-xr-x[-rw-r--r--] | silx/gui/plot/backends/BackendMatplotlib.py | 519 | ||||
-rwxr-xr-x[-rw-r--r--] | silx/gui/plot/backends/BackendOpenGL.py | 990 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLPlotCurve.py | 165 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLPlotImage.py | 4 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLPlotTriangles.py | 9 |
6 files changed, 894 insertions, 880 deletions
diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py index af37543..75d999b 100644..100755 --- a/silx/gui/plot/backends/BackendBase.py +++ b/silx/gui/plot/backends/BackendBase.py @@ -97,16 +97,15 @@ class BackendBase(object): # Add methods - def addCurve(self, x, y, legend, + def addCurve(self, x, y, color, symbol, linewidth, linestyle, yaxis, - xerror, yerror, z, selectable, - fill, alpha, symbolsize): + xerror, yerror, z, + fill, alpha, symbolsize, baseline): """Add a 1D curve given by x an y to the graph. :param numpy.ndarray x: The data corresponding to the x axis :param numpy.ndarray y: The data corresponding to the y axis - :param str legend: The legend to be associated to the curve :param color: color(s) to be used :type color: string ("#RRGGBB") or (npoints, 4) unsigned byte array or one of the predefined color names defined in colors.py @@ -136,24 +135,21 @@ class BackendBase(object): :param yerror: Values with the uncertainties on the y values :type yerror: numpy.ndarray or None :param int z: Layer on which to draw the cuve - :param bool selectable: indicate if the curve can be selected :param bool fill: True to fill the curve, False otherwise :param float alpha: Curve opacity, as a float in [0., 1.] :param float symbolsize: Size of the symbol (if any) drawn at each (x, y) position. :returns: The handle used by the backend to univocally access the curve """ - return legend + return object() - def addImage(self, data, legend, + def addImage(self, data, origin, scale, z, - selectable, draggable, colormap, alpha): """Add an image to the plot. :param numpy.ndarray data: (nrows, ncolumns) data or (nrows, ncolumns, RGBA) ubyte array - :param str legend: The legend to be associated to the image :param origin: (origin X, origin Y) of the data. Default: (0., 0.) :type origin: 2-tuple of float @@ -161,39 +157,34 @@ class BackendBase(object): Default: (1., 1.) :type scale: 2-tuple of float :param int z: Layer on which to draw the image - :param bool selectable: indicate if the image can be selected - :param bool draggable: indicate if the image can be moved :param ~silx.gui.colors.Colormap colormap: Colormap object to use. Ignored if data is RGB(A). :param float alpha: Opacity of the image, as a float in range [0, 1]. :returns: The handle used by the backend to univocally access the image """ - return legend + return object() - def addTriangles(self, x, y, triangles, legend, - color, z, selectable, alpha): + def addTriangles(self, x, y, triangles, + color, z, alpha): """Add a set of triangles. :param numpy.ndarray x: The data corresponding to the x axis :param numpy.ndarray y: The data corresponding to the y axis :param numpy.ndarray triangles: The indices to make triangles as a (Ntriangle, 3) array - :param str legend: The legend to be associated to the curve :param numpy.ndarray color: color(s) as (npoints, 4) array :param int z: Layer on which to draw the cuve - :param bool selectable: indicate if the curve can be selected :param float alpha: Opacity as a float in [0., 1.] :returns: The triangles' unique identifier used by the backend """ - return legend + return object() - def addItem(self, x, y, legend, shape, color, fill, overlay, z, + def addItem(self, x, y, shape, color, fill, overlay, z, linestyle, linewidth, linebgcolor): """Add an item (i.e. a shape) to the plot. :param numpy.ndarray x: The X coords of the points of the shape :param numpy.ndarray y: The Y coords of the points of the shape - :param str legend: The legend to be associated to the item :param str shape: Type of item to be drawn in hline, polygon, rectangle, vline, polylines :param str color: Color of the item @@ -215,22 +206,18 @@ class BackendBase(object): '#FF0000'. It is used to draw dotted line using a second color. :returns: The handle used by the backend to univocally access the item """ - return legend + return object() - def addMarker(self, x, y, legend, text, color, - selectable, draggable, - symbol, linestyle, linewidth, constraint): + def addMarker(self, x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis): """Add a point, vertical line or horizontal line marker to the plot. :param float x: Horizontal position of the marker in graph coordinates. If None, the marker is a horizontal line. :param float y: Vertical position of the marker in graph coordinates. If None, the marker is a vertical line. - :param str legend: Legend associated to the marker :param str text: Text associated to the marker (or None for no text) :param str color: Color to be used for instance 'blue', 'b', '#FF0000' - :param bool selectable: indicate if the marker can be selected - :param bool draggable: indicate if the marker can be moved :param str symbol: Symbol representing the marker. Only relevant for point markers where X and Y are not None. Value in: @@ -257,13 +244,13 @@ class BackendBase(object): dragging operations or None for no filter. This function is called each time a marker is moved. - This parameter is only used if draggable is True. :type constraint: None or a callable that takes the coordinates of the current cursor position in the plot as input and that returns the filtered coordinates. + :param str yaxis: The Y axis this marker belongs to in: 'left', 'right' :return: Handle used by the backend to univocally access the marker """ - return legend + return object() # Remove methods @@ -307,22 +294,40 @@ class BackendBase(object): """ pass - def pickItems(self, x, y, kinds): - """Get a list of items at a pixel position. + def getItemsFromBackToFront(self, condition=None): + """Returns the list of plot items order as rendered by the backend. + + This is the order used for rendering. + By default, it takes into account overlays, z value and order of addition of items, + but backends can override it. + + :param callable condition: + Callable taking an item as input and returning False for items to skip. + If None (default), no item is skipped. + :rtype: List[~silx.gui.plot.items.Item] + """ + # Sort items: Overlays first, then others + # and in each category ordered by z and then by order of addition + # as content keeps this order. + content = self._plot.getItems() + if condition is not None: + content = [item for item in content if condition(item)] + + return sorted( + content, + key=lambda i: ((1 if i.isOverlay() else 0), i.getZValue())) + + def pickItem(self, x, y, item): + """Return picked indices if any, or None. :param float x: The x pixel coord where to pick. :param float y: The y pixel coord where to pick. - :param List[str] kind: List of item kinds to pick. - Supported kinds: 'marker', 'curve', 'image'. - :return: All picked items from back to front. - One dict per item, - with 'kind' key in 'curve', 'marker', 'image'; - 'legend' key, the item legend. - and for curves, 'xdata' and 'ydata' keys storing picked - position on the curve. - :rtype: list of dict - """ - return [] + :param item: A backend item created with add* methods. + :return: None if item was not picked, else returns + picked indices information. + :rtype: Union[None,List] + """ + return None # Update curve diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py index 7739329..2336494 100644..100755 --- a/silx/gui/plot/backends/BackendMatplotlib.py +++ b/silx/gui/plot/backends/BackendMatplotlib.py @@ -56,12 +56,13 @@ from matplotlib.collections import PathCollection, LineCollection from matplotlib.ticker import Formatter, ScalarFormatter, Locator from matplotlib.tri import Triangulation from matplotlib.collections import TriMesh +from matplotlib import path as mpath from . import BackendBase +from .. import items from .._utils import FLOAT32_MINPOS from .._utils.dtime_ticklayout import calcTicks, bestFormatString, timestamp - _PATCH_LINESTYLE = { "-": 'solid', "--": 'dashed', @@ -72,11 +73,54 @@ _PATCH_LINESTYLE = { } """Patches do not uses the same matplotlib syntax""" +_MARKER_PATHS = {} +"""Store cached extra marker paths""" + +_SPECIAL_MARKERS = { + 'tickleft': 0, + 'tickright': 1, + 'tickup': 2, + 'tickdown': 3, + 'caretleft': 4, + 'caretright': 5, + 'caretup': 6, + 'caretdown': 7, +} + def normalize_linestyle(linestyle): """Normalize known old-style linestyle, else return the provided value.""" return _PATCH_LINESTYLE.get(linestyle, linestyle) +def get_path_from_symbol(symbol): + """Get the path representation of a symbol, else None if + it is not provided. + + :param str symbol: Symbol description used by silx + :rtype: Union[None,matplotlib.path.Path] + """ + if symbol == u'\u2665': + path = _MARKER_PATHS.get(symbol, None) + if path is not None: + return path + vertices = numpy.array([ + [0,-99], + [31,-73], [47,-55], [55,-46], + [63,-37], [94,-2], [94,33], + [94,69], [71,89], [47,89], + [24,89], [8,74], [0,58], + [-8,74], [-24,89], [-47,89], + [-71,89], [-94,69], [-94,33], + [-94,-2], [-63,-37], [-55,-46], + [-47,-55], [-31,-73], [0,-99], + [0,-99]]) + codes = [mpath.Path.CURVE4] * len(vertices) + codes[0] = mpath.Path.MOVETO + codes[-1] = mpath.Path.CLOSEPOLY + path = mpath.Path(vertices, codes) + _MARKER_PATHS[symbol] = path + return path + return None class NiceDateLocator(Locator): """ @@ -162,7 +206,53 @@ class NiceAutoDateFormatter(Formatter): return tickStr -class _MarkerContainer(Container): +class _PickableContainer(Container): + """Artists container with a :meth:`contains` method""" + + def __init__(self, *args, **kwargs): + Container.__init__(self, *args, **kwargs) + self.__zorder = None + + @property + def axes(self): + """Mimin Artist.axes""" + for child in self.get_children(): + if hasattr(child, 'axes'): + return child.axes + return None + + def draw(self, *args, **kwargs): + """artist-like draw to broadcast draw to children""" + for child in self.get_children(): + child.draw(*args, **kwargs) + + def get_zorder(self): + """Mimic Artist.get_zorder""" + return self.__zorder + + def set_zorder(self, z): + """Mimic Artist.set_zorder to broadcast to children""" + if z != self.__zorder: + self.__zorder = z + for child in self.get_children(): + child.set_zorder(z) + + def contains(self, mouseevent): + """Mimic Artist.contains, and call it on all children. + + :param mouseevent: + :return: Picking status and associated information as a dict + :rtype: (bool,dict) + """ + # Goes through children from front to back and return first picked one. + for child in reversed(self.get_children()): + picked, info = child.contains(mouseevent) + if picked: + return picked, info + return False, {} + + +class _MarkerContainer(_PickableContainer): """Marker artists container supporting draw/remove and text position update :param artists: @@ -173,13 +263,14 @@ class _MarkerContainer(Container): :param y: Y coordinate of the marker (None for vertical lines) """ - def __init__(self, artists, x, y): + def __init__(self, artists, x, y, yAxis): self.line = artists[0] self.text = artists[1] if len(artists) > 1 else None self.x = x self.y = y + self.yAxis = yAxis - Container.__init__(self, artists) + _PickableContainer.__init__(self, artists) def draw(self, *args, **kwargs): """artist-like draw to broadcast draw to line and text""" @@ -214,6 +305,15 @@ class _MarkerContainer(Container): xmax -= 0.005 * delta self.text.set_x(xmax) + def contains(self, mouseevent): + """Mimic Artist.contains, and call it on the line Artist. + + :param mouseevent: + :return: Picking status and associated information as a dict + :rtype: (bool,dict) + """ + return self.line.contains(mouseevent) + class _DoubleColoredLinePatch(matplotlib.patches.Patch): """Matplotlib patch to display any patch using double color.""" @@ -294,7 +394,11 @@ class BackendMatplotlib(BackendBase.BackendBase): self.ax2 = self.ax.twinx() self.ax2.set_label("right") # Make sure background of Axes is displayed - self.ax2.patch.set_visible(True) + self.ax2.patch.set_visible(False) + self.ax.patch.set_visible(True) + + # Set axis zorder=0.5 so grid is displayed at 0.5 + self.ax.set_axisbelow(True) # disable the use of offsets try: @@ -306,10 +410,8 @@ class BackendMatplotlib(BackendBase.BackendBase): _logger.warning('Cannot disabled axes offsets in %s ' % matplotlib.__version__) - # critical for picking!!!! - self.ax2.set_zorder(0) self.ax2.set_autoscaley_on(True) - self.ax.set_zorder(1) + # this works but the figure color is left if self._matplotlibVersion < _parse_version('2'): self.ax.set_axis_bgcolor('none') @@ -317,7 +419,6 @@ class BackendMatplotlib(BackendBase.BackendBase): self.ax.set_facecolor('none') self.fig.sca(self.ax) - self._overlays = set() self._background = None self._colormaps = {} @@ -327,15 +428,67 @@ class BackendMatplotlib(BackendBase.BackendBase): self._enableAxis('right', False) self._isXAxisTimeSeries = False + def getItemsFromBackToFront(self, condition=None): + """Order as BackendBase + take into account matplotlib Axes structure""" + def axesOrder(item): + if item.isOverlay(): + return 2 + elif isinstance(item, items.YAxisMixIn) and item.getYAxis() == 'right': + return 1 + else: + return 0 + + return sorted( + BackendBase.BackendBase.getItemsFromBackToFront( + self, condition=condition), + key=axesOrder) + + def _overlayItems(self): + """Generator of backend renderer for overlay items""" + for item in self._plot.getItems(): + if (item.isOverlay() and + item.isVisible() and + item._backendRenderer is not None): + yield item._backendRenderer + + def _hasOverlays(self): + """Returns whether there is an overlay layer or not. + + The overlay layers contains overlay items and the crosshair. + + :rtype: bool + """ + if self._graphCursor: + return True # There is the crosshair + + for item in self._overlayItems(): + return True # There is at least one overlay item + return False + # Add methods - def addCurve(self, x, y, legend, + def _getMarkerFromSymbol(self, symbol): + """Returns a marker that can be displayed by matplotlib. + + :param str symbol: A symbol description used by silx + :rtype: Union[str,int,matplotlib.path.Path] + """ + path = get_path_from_symbol(symbol) + if path is not None: + return path + num = _SPECIAL_MARKERS.get(symbol, None) + if num is not None: + return num + # This symbol must be supported by matplotlib + return symbol + + def addCurve(self, x, y, 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, alpha, symbolsize): + xerror, yerror, z, + fill, alpha, symbolsize, baseline): + for parameter in (x, y, color, symbol, linewidth, linestyle, + yaxis, z, fill, alpha, symbolsize): assert parameter is not None assert yaxis in ('left', 'right') @@ -349,7 +502,7 @@ class BackendMatplotlib(BackendBase.BackendBase): else: axes = self.ax - picker = 3 if selectable else None + picker = 3 artists = [] # All the artists composing the curve @@ -368,7 +521,7 @@ class BackendMatplotlib(BackendBase.BackendBase): yerror.shape[1] == 1): yerror = numpy.ravel(yerror) - errorbars = axes.errorbar(x, y, label=legend, + errorbars = axes.errorbar(x, y, xerr=xerror, yerr=yerror, linestyle=' ', color=errorbarColor) artists += list(errorbars.get_children()) @@ -383,7 +536,7 @@ class BackendMatplotlib(BackendBase.BackendBase): if linestyle not in ["", " ", None]: # scatter plot with an actual line ... # we need to assign a color ... - curveList = axes.plot(x, y, label=legend, + curveList = axes.plot(x, y, linestyle=linestyle, color=actualColor[0], linewidth=linewidth, @@ -391,21 +544,24 @@ class BackendMatplotlib(BackendBase.BackendBase): marker=None) artists += list(curveList) + marker = self._getMarkerFromSymbol(symbol) scatter = axes.scatter(x, y, - label=legend, color=actualColor, - marker=symbol, + marker=marker, picker=picker, s=symbolsize**2) artists.append(scatter) if fill: + if baseline is None: + _baseline = FLOAT32_MINPOS + else: + _baseline = baseline artists.append(axes.fill_between( - x, FLOAT32_MINPOS, y, facecolor=actualColor[0], linestyle='')) + x, _baseline, y, facecolor=actualColor[0], linestyle='')) else: # Curve curveList = axes.plot(x, y, - label=legend, linestyle=linestyle, color=color, linewidth=linewidth, @@ -415,40 +571,35 @@ class BackendMatplotlib(BackendBase.BackendBase): artists += list(curveList) if fill: + if baseline is None: + _baseline = FLOAT32_MINPOS + else: + _baseline = baseline artists.append( - axes.fill_between(x, FLOAT32_MINPOS, y, facecolor=color)) + axes.fill_between(x, _baseline, y, facecolor=color)) for artist in artists: - artist.set_zorder(z) if alpha < 1: artist.set_alpha(alpha) - return Container(artists) + return _PickableContainer(artists) - def addImage(self, data, legend, - origin, scale, z, - selectable, draggable, - colormap, alpha): + def addImage(self, data, origin, scale, z, colormap, alpha): # Non-uniform image # http://wiki.scipy.org/Cookbook/Histograms # Non-linear axes # http://stackoverflow.com/questions/11488800/non-linear-axes-for-imshow-in-matplotlib - for parameter in (data, legend, origin, scale, z, - selectable, draggable): + for parameter in (data, origin, scale, z): assert parameter is not None origin = float(origin[0]), float(origin[1]) scale = float(scale[0]), float(scale[1]) height, width = data.shape[0:2] - picker = (selectable or draggable) - # All image are shown as RGBA image image = Image(self.ax, - label="__IMAGE__" + legend, interpolation='nearest', - picker=picker, - zorder=z, + picker=True, origin='lower') if alpha < 1: @@ -481,15 +632,10 @@ class BackendMatplotlib(BackendBase.BackendBase): self.ax.add_artist(image) return image - def addTriangles(self, x, y, triangles, legend, - color, z, selectable, alpha): - for parameter in (x, y, triangles, legend, color, - z, selectable, alpha): + def addTriangles(self, x, y, triangles, color, z, alpha): + for parameter in (x, y, triangles, color, z, alpha): assert parameter is not None - # 0 enables picking on filled triangle - picker = 0 if selectable else None - color = numpy.array(color, copy=False) assert color.ndim == 2 and len(color) == len(x) @@ -498,16 +644,14 @@ class BackendMatplotlib(BackendBase.BackendBase): collection = TriMesh( Triangulation(x, y, triangles), - label=legend, alpha=alpha, - picker=picker, - zorder=z) + picker=0) # 0 enables picking on filled triangle collection.set_color(color) self.ax.add_collection(collection) return collection - def addItem(self, x, y, legend, shape, color, fill, overlay, z, + def addItem(self, x, y, shape, color, fill, overlay, z, linestyle, linewidth, linebgcolor): if (linebgcolor is not None and shape not in ('rectangle', 'polygon', 'polylines')): @@ -520,20 +664,20 @@ class BackendMatplotlib(BackendBase.BackendBase): linestyle = normalize_linestyle(linestyle) if shape == "line": - item = self.ax.plot(x, y, label=legend, color=color, + item = self.ax.plot(x, y, color=color, linestyle=linestyle, linewidth=linewidth, marker=None)[0] elif shape == "hline": if hasattr(y, "__len__"): y = y[-1] - item = self.ax.axhline(y, label=legend, color=color, + item = self.ax.axhline(y, color=color, linestyle=linestyle, linewidth=linewidth) elif shape == "vline": if hasattr(x, "__len__"): x = x[-1] - item = self.ax.axvline(x, label=legend, color=color, + item = self.ax.axvline(x, color=color, linestyle=linestyle, linewidth=linewidth) elif shape == 'rectangle': @@ -568,7 +712,6 @@ class BackendMatplotlib(BackendBase.BackendBase): item = Polygon(points, closed=closed, fill=False, - label=legend, color=color, linestyle=linestyle, linewidth=linewidth) @@ -584,30 +727,32 @@ class BackendMatplotlib(BackendBase.BackendBase): else: raise NotImplementedError("Unsupported item shape %s" % shape) - item.set_zorder(z) - if overlay: item.set_animated(True) - self._overlays.add(item) return item - def addMarker(self, x, y, legend, text, color, - selectable, draggable, - symbol, linestyle, linewidth, constraint): - legend = "__MARKER__" + legend - + def addMarker(self, x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis): textArtist = None xmin, xmax = self.getGraphXLimits() - ymin, ymax = self.getGraphYLimits(axis='left') + ymin, ymax = self.getGraphYLimits(axis=yaxis) + + if yaxis == 'left': + ax = self.ax + elif yaxis == 'right': + ax = self.ax2 + else: + assert(False) + marker = self._getMarkerFromSymbol(symbol) if x is not None and y is not None: - line = self.ax.plot(x, y, label=legend, - linestyle=" ", - color=color, - marker=symbol, - markersize=10.)[-1] + line = ax.plot(x, y, + linestyle=" ", + color=color, + marker=marker, + markersize=10.)[-1] if text is not None: if symbol is None: @@ -616,43 +761,40 @@ class BackendMatplotlib(BackendBase.BackendBase): valign = 'top' text = " " + text - textArtist = self.ax.text(x, y, text, - color=color, - horizontalalignment='left', - verticalalignment=valign) + textArtist = ax.text(x, y, text, + color=color, + horizontalalignment='left', + verticalalignment=valign) elif x is not None: - line = self.ax.axvline(x, - label=legend, - color=color, - linewidth=linewidth, - linestyle=linestyle) + line = ax.axvline(x, + color=color, + linewidth=linewidth, + linestyle=linestyle) if text is not None: # Y position will be updated in updateMarkerText call - textArtist = self.ax.text(x, 1., " " + text, - color=color, - horizontalalignment='left', - verticalalignment='top') + textArtist = ax.text(x, 1., " " + text, + color=color, + horizontalalignment='left', + verticalalignment='top') elif y is not None: - line = self.ax.axhline(y, - label=legend, - color=color, - linewidth=linewidth, - linestyle=linestyle) + line = ax.axhline(y, + color=color, + linewidth=linewidth, + linestyle=linestyle) if text is not None: # X position will be updated in updateMarkerText call - textArtist = self.ax.text(1., y, " " + text, - color=color, - horizontalalignment='right', - verticalalignment='top') + textArtist = ax.text(1., y, " " + text, + color=color, + horizontalalignment='right', + verticalalignment='top') else: raise RuntimeError('A marker must at least have one coordinate') - if selectable or draggable: - line.set_picker(5) + line.set_picker(5) # All markers are overlays line.set_animated(True) @@ -660,24 +802,25 @@ class BackendMatplotlib(BackendBase.BackendBase): textArtist.set_animated(True) artists = [line] if textArtist is None else [line, textArtist] - container = _MarkerContainer(artists, x, y) + container = _MarkerContainer(artists, x, y, yaxis) container.updateMarkerText(xmin, xmax, ymin, ymax) - self._overlays.add(container) return container def _updateMarkers(self): xmin, xmax = self.ax.get_xbound() - ymin, ymax = self.ax.get_ybound() - for item in self._overlays: + ymin1, ymax1 = self.ax.get_ybound() + ymin2, ymax2 = self.ax2.get_ybound() + for item in self._overlayItems(): if isinstance(item, _MarkerContainer): - item.updateMarkerText(xmin, xmax, ymin, ymax) + if item.yAxis == 'left': + item.updateMarkerText(xmin, xmax, ymin1, ymax1) + else: + item.updateMarkerText(xmin, xmax, ymin2, ymax2) # Remove methods def remove(self, item): - # Warning: It also needs to remove extra stuff if added as for markers - self._overlays.discard(item) try: item.remove() except ValueError: @@ -699,7 +842,7 @@ class BackendMatplotlib(BackendBase.BackendBase): self._graphCursor = lineh, linev else: - if self._graphCursor is not None: + if self._graphCursor: lineh, linev = self._graphCursor lineh.remove() linev.remove() @@ -746,7 +889,37 @@ class BackendMatplotlib(BackendBase.BackendBase): if not self.ax2.lines: self._enableAxis('right', False) + def _drawOverlays(self): + """Draw overlays if any.""" + def condition(item): + return (item.isVisible() and + item._backendRenderer is not None and + item.isOverlay()) + + for item in self.getItemsFromBackToFront(condition=condition): + if (isinstance(item, items.YAxisMixIn) and + item.getYAxis() == 'right'): + axes = self.ax2 + else: + axes = self.ax + axes.draw_artist(item._backendRenderer) + + for item in self._graphCursor: + self.ax.draw_artist(item) + + def updateZOrder(self): + """Reorder all items with z order from 0 to 1""" + items = self.getItemsFromBackToFront( + lambda item: item.isVisible() and item._backendRenderer is not None) + count = len(items) + for index, item in enumerate(items): + zorder = 1. + index / count + if zorder != item._backendRenderer.get_zorder(): + item._backendRenderer.set_zorder(zorder) + def saveGraph(self, fileName, fileFormat, dpi): + self.updateZOrder() + # fileName can be also a StringIO or file instance if dpi is not None: self.fig.savefig(fileName, format=fileFormat, dpi=dpi) @@ -788,7 +961,9 @@ class BackendMatplotlib(BackendBase.BackendBase): def getGraphXLimits(self): if self._dirtyLimits and self.isKeepDataAspectRatio(): - self.replot() # makes sure we get the right limits + self.ax.apply_aspect() + self.ax2.apply_aspect() + self._dirtyLimits = False return self.ax.get_xbound() def setGraphXLimits(self, xmin, xmax): @@ -804,7 +979,9 @@ class BackendMatplotlib(BackendBase.BackendBase): return None if self._dirtyLimits and self.isKeepDataAspectRatio(): - self.replot() # makes sure we get the right limits + self.ax.apply_aspect() + self.ax2.apply_aspect() + self._dirtyLimits = False return ax.get_ybound() @@ -917,13 +1094,16 @@ class BackendMatplotlib(BackendBase.BackendBase): # Data <-> Pixel coordinates conversion - def _mplQtYAxisCoordConversion(self, y): + def _mplQtYAxisCoordConversion(self, y, asint=True): """Qt origin (top) to/from matplotlib origin (bottom) conversion. + :param y: + :param bool asint: True to cast to int, False to keep as float + :rtype: float """ - height = self.fig.get_window_extent().height - return height - y + value = self.fig.get_window_extent().height - y + return int(value) if asint else value def dataToPixel(self, x, y, axis): ax = self.ax2 if axis == "right" else self.ax @@ -932,35 +1112,27 @@ class BackendMatplotlib(BackendBase.BackendBase): xPixel, yPixel = pixels.T # Convert from matplotlib origin (bottom) to Qt origin (top) - yPixel = self._mplQtYAxisCoordConversion(yPixel) + yPixel = self._mplQtYAxisCoordConversion(yPixel, asint=False) return xPixel, yPixel - def pixelToData(self, x, y, axis, check): + def pixelToData(self, x, y, axis): ax = self.ax2 if axis == "right" else self.ax # Convert from Qt origin (top) to matplotlib origin (bottom) - y = self._mplQtYAxisCoordConversion(y) + y = self._mplQtYAxisCoordConversion(y, asint=False) inv = ax.transData.inverted() x, y = inv.transform_point((x, y)) - - if check: - xmin, xmax = self.getGraphXLimits() - ymin, ymax = self.getGraphYLimits(axis=axis) - - if x > xmax or x < xmin or y > ymax or y < ymin: - return None # (x, y) is out of plot area - return x, y def getPlotBoundsInPixels(self): bbox = self.ax.get_window_extent() # Warning this is not returning int... - return (bbox.xmin, - self._mplQtYAxisCoordConversion(bbox.ymax), - bbox.width, - bbox.height) + return (int(bbox.xmin), + self._mplQtYAxisCoordConversion(bbox.ymax, asint=True), + int(bbox.width), + int(bbox.height)) def setAxesDisplayed(self, displayed): """Display or not the axes. @@ -996,12 +1168,12 @@ class BackendMatplotlib(BackendBase.BackendBase): else: dataBackgroundColor = backgroundColor - if self.ax2.axison: + if self.ax.axison: self.fig.patch.set_facecolor(backgroundColor) if self._matplotlibVersion < _parse_version('2'): - self.ax2.set_axis_bgcolor(dataBackgroundColor) + self.ax.set_axis_bgcolor(dataBackgroundColor) else: - self.ax2.set_facecolor(dataBackgroundColor) + self.ax.set_facecolor(dataBackgroundColor) else: self.fig.patch.set_facecolor(dataBackgroundColor) @@ -1033,6 +1205,12 @@ class BackendMatplotlib(BackendBase.BackendBase): line.set_color(gridColor) # axes.grid().set_markeredgecolor(gridColor) + def setBackgroundColors(self, backgroundColor, dataBackgroundColor): + self._synchronizeBackgroundColors() + + def setForegroundColors(self, foregroundColor, gridColor): + self._synchronizeForegroundColors() + class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): """QWidget matplotlib backend using a QtAgg canvas. @@ -1079,14 +1257,16 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): _MPL_TO_PLOT_BUTTONS = {1: 'left', 2: 'middle', 3: 'right'} def _onMousePress(self, event): - self._plot.onMousePress( - event.x, self._mplQtYAxisCoordConversion(event.y), - self._MPL_TO_PLOT_BUTTONS[event.button]) + button = self._MPL_TO_PLOT_BUTTONS.get(event.button, None) + if button is not None: + self._plot.onMousePress( + event.x, self._mplQtYAxisCoordConversion(event.y), + button) def _onMouseMove(self, event): if self._graphCursor: lineh, linev = self._graphCursor - if event.inaxes != self.ax and lineh.get_visible(): + if event.inaxes not in (self.ax, self.ax2) and lineh.get_visible(): lineh.set_visible(False) linev.set_visible(False) self._plot._setDirtyPlot(overlayOnly=True) @@ -1102,9 +1282,11 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): event.x, self._mplQtYAxisCoordConversion(event.y)) def _onMouseRelease(self, event): - self._plot.onMouseRelease( - event.x, self._mplQtYAxisCoordConversion(event.y), - self._MPL_TO_PLOT_BUTTONS[event.button]) + button = self._MPL_TO_PLOT_BUTTONS.get(event.button, None) + if button is not None: + self._plot.onMouseRelease( + event.x, self._mplQtYAxisCoordConversion(event.y), + button) def _onMouseWheel(self, event): self._plot.onMouseWheel( @@ -1116,58 +1298,31 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): # picking - def _onPick(self, event): - # TODO not very nice and fragile, find a better way? - # Make a selection according to kind - if self._picked is None: - _logger.error('Internal picking error') - return + def pickItem(self, x, y, item): + mouseEvent = MouseEvent( + 'button_press_event', self, x, self._mplQtYAxisCoordConversion(y)) + mouseEvent.inaxes = item.axes + picked, info = item.contains(mouseEvent) - label = event.artist.get_label() - if label.startswith('__MARKER__'): - self._picked.append({'kind': 'marker', 'legend': label[10:]}) - - elif label.startswith('__IMAGE__'): - self._picked.append({'kind': 'image', 'legend': label[9:]}) + if not picked: + return None - elif isinstance(event.artist, TriMesh): + elif isinstance(item, TriMesh): # Convert selected triangle to data point indices - triangulation = event.artist._triangulation - indices = triangulation.get_masked_triangles()[event.ind[0]] + triangulation = item._triangulation + indices = triangulation.get_masked_triangles()[info['ind'][0]] # Sort picked triangle points by distance to mouse # from furthest to closest to put closest point last # This is to be somewhat consistent with last scatter point # being the top one. - dists = ((triangulation.x[indices] - event.mouseevent.xdata) ** 2 + - (triangulation.y[indices] - event.mouseevent.ydata) ** 2) - indices = indices[numpy.flip(numpy.argsort(dists))] + xdata, ydata = self.pixelToData(x, y, axis='left') + dists = ((triangulation.x[indices] - xdata) ** 2 + + (triangulation.y[indices] - ydata) ** 2) + return indices[numpy.flip(numpy.argsort(dists), axis=0)] - self._picked.append({'kind': 'curve', 'legend': label, - 'indices': indices}) - - else: # it's a curve, item have no picker for now - if not isinstance(event.artist, (PathCollection, Line2D)): - _logger.info('Unsupported artist, ignored') - return - - self._picked.append({'kind': 'curve', 'legend': label, - 'indices': event.ind}) - - def pickItems(self, x, y, kinds): - self._picked = [] - - # Weird way to do an explicit picking: Simulate a button press event - mouseEvent = MouseEvent('button_press_event', - self, x, self._mplQtYAxisCoordConversion(y)) - cid = self.mpl_connect('pick_event', self._onPick) - self.fig.pick(mouseEvent) - self.mpl_disconnect(cid) - - picked = [p for p in self._picked if p['kind'] in kinds] - self._picked = None - - return picked + else: # Returns indices if any + return info.get('ind', ()) # replot control @@ -1177,22 +1332,10 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): self.ax.get_xbound(), self.ax.get_ybound(), self.ax2.get_ybound()) FigureCanvasQTAgg.resizeEvent(self, event) - if self.isKeepDataAspectRatio() or self._overlays or self._graphCursor: + if self.isKeepDataAspectRatio() or self._hasOverlays(): # This is needed with matplotlib 1.5.x and 2.0.x self._plot._setDirtyPlot() - def _drawOverlays(self): - """Draw overlays if any.""" - if self._overlays or self._graphCursor: - # There is some overlays or crosshair - - # This assume that items are only on left/bottom Axes - for item in self._overlays: - self.ax.draw_artist(item) - - for item in self._graphCursor: - self.ax.draw_artist(item) - def draw(self): """Overload draw @@ -1201,6 +1344,8 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): This is directly called by matplotlib for widget resize. """ + self.updateZOrder() + # Starting with mpl 2.1.0, toggling autoscale raises a ValueError # in some situations. See #1081, #1136, #1163, if self._matplotlibVersion >= _parse_version("2.0.0"): @@ -1213,7 +1358,7 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): else: FigureCanvasQTAgg.draw(self) - if self._overlays or self._graphCursor: + if self._hasOverlays(): # Save background self._background = self.copy_from_bbox(self.fig.bbox) else: @@ -1257,7 +1402,7 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): if (_parse_version('1.5') <= self._matplotlibVersion < _parse_version('2.1') and not hasattr(self, '_firstReplot')): self._firstReplot = False - if self._overlays or self._graphCursor: + if self._hasOverlays(): qt.QTimer.singleShot(0, self.draw) # Request async draw # cursor @@ -1276,9 +1421,3 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): else: cursor = self._QT_CURSORS[cursor] FigureCanvasQTAgg.setCursor(self, qt.QCursor(cursor)) - - def setBackgroundColors(self, backgroundColor, dataBackgroundColor): - self._synchronizeBackgroundColors() - - def setForegroundColors(self, foregroundColor, gridColor): - self._synchronizeForegroundColors() diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py index 0420aa9..27f3894 100644..100755 --- a/silx/gui/plot/backends/BackendOpenGL.py +++ b/silx/gui/plot/backends/BackendOpenGL.py @@ -30,13 +30,13 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" __date__ = "21/12/2018" -from collections import OrderedDict, namedtuple import logging import warnings import weakref import numpy +from .. import items from .._utils import FLOAT32_MINPOS from . import BackendBase from ... import colors @@ -59,186 +59,66 @@ _logger = logging.getLogger(__name__) # 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', 'triangles' - - 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' - elif isinstance(primitive, GLPlotTriangles): - primitiveType = 'triangles' - else: - raise RuntimeError('Unsupported object type: %s', primitive) - - key = primitiveType, primitive.info['legend'] - self._primitives[key] = primitive +class _ShapeItem(dict): + def __init__(self, x, y, shape, color, fill, overlay, z, + linestyle, linewidth, linebgcolor): + super(_ShapeItem, self).__init__() - def get(self, primitiveType, legend): - """Get the corresponding primitive of given type with given legend. + if shape not in ('polygon', 'rectangle', 'line', + 'vline', 'hline', 'polylines'): + raise NotImplementedError("Unsupported shape {0}".format(shape)) - :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)) + x = numpy.array(x, copy=False) + y = numpy.array(y, copy=False) - def pop(self, primitiveType, key): - """Pop the corresponding curve or return None if no such curve. + if shape == 'rectangle': + xMin, xMax = x + x = numpy.array((xMin, xMin, xMax, xMax)) + yMin, yMax = y + y = numpy.array((yMin, yMax, yMax, yMin)) - :param str primitiveType: - :param str key: - :return: - """ - assert primitiveType in self._PRIMITIVE_TYPES - return self._primitives.pop((primitiveType, key), None) + # Ignore fill for polylines to mimic matplotlib + fill = fill if shape != 'polylines' else False - def zOrderedPrimitives(self, reverse=False): - """List of primitives sorted according to their z order. + self.update({ + 'shape': shape, + 'color': colors.rgba(color), + 'fill': 'hatch' if fill else None, + 'x': x, + 'y': y, + 'linestyle': linestyle, + 'linewidth': linewidth, + 'linebgcolor': linebgcolor, + }) - 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 +class _MarkerItem(dict): + def __init__(self, x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis): + super(_MarkerItem, self).__init__() - 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 + if symbol is None: + symbol = '+' - # 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. + # Apply constraint to provided position + isConstraint = (constraint is not None and + x is not None and y is not None) + if isConstraint: + x, y = constraint(x, y) - return Bounds(xMin, xMax, yMin, yMax, y2Min, y2Max) + self.update({ + 'x': x, + 'y': y, + 'text': text, + 'color': colors.rgba(color), + 'constraint': constraint if isConstraint else None, + 'symbol': symbol, + 'linestyle': linestyle, + 'linewidth': linewidth, + 'yaxis': yaxis, + }) # shaders ##################################################################### @@ -350,9 +230,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): self._crosshairCursor = None self._mousePosInPixels = None - self._markers = OrderedDict() - self._items = OrderedDict() - self._plotContent = PlotDataContent() # For images and curves self._glGarbageCollector = [] self._plotFrame = GLPlotFrame2D( @@ -457,7 +334,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): def _paintDirectGL(self): self._renderPlotAreaGL() self._plotFrame.render() - self._renderMarkersGL() self._renderOverlayGL() def _paintFBOGL(self): @@ -522,7 +398,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): with plotFBOTex.texture: gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(self._plotVertices[0])) - self._renderMarkersGL() self._renderOverlayGL() def paintGL(self): @@ -543,120 +418,203 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): # self._paintDirectGL() self._paintFBOGL() - def _renderMarkersGL(self): - if len(self._markers) == 0: - return + def _renderItems(self, overlay=False): + """Render items according to :class:`PlotWidget` order - plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] + Note: Scissor test should already be set. - # Render in plot area - gl.glScissor(self._plotFrame.margins.left, - self._plotFrame.margins.bottom, - plotWidth, plotHeight) - gl.glEnable(gl.GL_SCISSOR_TEST) - - gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + :param bool overlay: + False (the default) to render item that are not overlays. + True to render items that are overlays. + """ + # Values that are often used + plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] + isXLog = self._plotFrame.xAxis.isLog + isYLog = self._plotFrame.yAxis.isLog + # Used by marker rendering labels = [] pixelOffset = 3 - for marker in self._markers.values(): - xCoord, yCoord = marker['x'], marker['y'] - - if ((self._plotFrame.xAxis.isLog and - xCoord is not None and - xCoord <= 0) or - (self._plotFrame.yAxis.isLog and - yCoord is not None and - yCoord <= 0)): - # Do not render markers with negative coords on log axis + for plotItem in self.getItemsFromBackToFront( + condition=lambda i: i.isVisible() and i.isOverlay() == overlay): + if plotItem._backendRenderer is None: continue - if xCoord is None or yCoord is None: - 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) + item = plotItem._backendRenderer + + if isinstance(item, (GLPlotCurve2D, + GLPlotColormap, + GLPlotRGBAImage, + GLPlotTriangles)): # Render data items + gl.glViewport(self._plotFrame.margins.left, + self._plotFrame.margins.bottom, + plotWidth, plotHeight) + if isinstance(item, GLPlotCurve2D) and item.info.get('yAxis') == 'right': + item.render(self._plotFrame.transformedDataY2ProjMat, + isXLog, isYLog) + else: + item.render(self._plotFrame.transformedDataProjMat, + isXLog, isYLog) + + elif isinstance(item, _ShapeItem): # Render shape items + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + + if ((isXLog and numpy.min(item['x']) < FLOAT32_MINPOS) or + (isYLog and numpy.min(item['y']) < FLOAT32_MINPOS)): + # Ignore items <= 0. on log axes + continue + + if item['shape'] == 'hline': width = self._plotFrame.size[0] - lines = GLLines2D((0, width), (pixelPos[1], pixelPos[1]), - style=marker['linestyle'], - color=marker['color'], - width=marker['linewidth']) + _, yPixel = self._plot.dataToPixel( + None, item['y'], axis='left', check=False) + points = numpy.array(((0., yPixel), (width, yPixel)), + dtype=numpy.float32) + + elif item['shape'] == 'vline': + xPixel, _ = self._plot.dataToPixel( + item['x'], None, axis='left', check=False) + height = self._plotFrame.size[1] + points = numpy.array(((xPixel, 0), (xPixel, height)), + dtype=numpy.float32) + + else: + points = numpy.array([ + self._plot.dataToPixel(x, y, axis='left', check=False) + for (x, y) in zip(item['x'], item['y'])]) + + # Draw the fill + if (item['fill'] is not None and + item['shape'] not in ('hline', 'vline')): + self._progBase.use() + gl.glUniformMatrix4fv( + self._progBase.uniforms['matrix'], 1, gl.GL_TRUE, + self.matScreenProj.astype(numpy.float32)) + gl.glUniform2i(self._progBase.uniforms['isLog'], False, False) + gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.) + + shape2D = FilledShape2D( + points, style=item['fill'], color=item['color']) + shape2D.render( + posAttrib=self._progBase.attributes['position'], + colorUnif=self._progBase.uniforms['color'], + hatchStepUnif=self._progBase.uniforms['hatchStep']) + + # Draw the stroke + if item['linestyle'] not in ('', ' ', None): + if item['shape'] != 'polylines': + # close the polyline + points = numpy.append(points, + numpy.atleast_2d(points[0]), axis=0) + + lines = GLLines2D(points[:, 0], points[:, 1], + style=item['linestyle'], + color=item['color'], + dash2ndColor=item['linebgcolor'], + width=item['linewidth']) lines.render(self.matScreenProj) - else: # yCoord is None: vertical line in data space - if marker['text'] is not None: + elif isinstance(item, _MarkerItem): + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + + xCoord, yCoord, yAxis = item['x'], item['y'], item['yaxis'] + + if ((isXLog and xCoord is not None and xCoord <= 0) or + (isYLog and yCoord is not None and yCoord <= 0)): + # Do not render markers with negative coords on log axis + continue + + if xCoord is None or yCoord is None: + pixelPos = self._plot.dataToPixel( + xCoord, yCoord, axis=yAxis, check=False) + + if xCoord is None: # Horizontal line in data space + if item['text'] is not None: + x = self._plotFrame.size[0] - \ + self._plotFrame.margins.right - pixelOffset + y = pixelPos[1] - pixelOffset + label = Text2D(item['text'], x, y, + color=item['color'], + bgColor=(1., 1., 1., 0.5), + align=RIGHT, valign=BOTTOM) + labels.append(label) + + width = self._plotFrame.size[0] + lines = GLLines2D((0, width), (pixelPos[1], pixelPos[1]), + style=item['linestyle'], + color=item['color'], + width=item['linewidth']) + lines.render(self.matScreenProj) + + else: # yCoord is None: vertical line in data space + if item['text'] is not None: + x = pixelPos[0] + pixelOffset + y = self._plotFrame.margins.top + pixelOffset + label = Text2D(item['text'], x, y, + color=item['color'], + bgColor=(1., 1., 1., 0.5), + align=LEFT, valign=TOP) + labels.append(label) + + height = self._plotFrame.size[1] + lines = GLLines2D((pixelPos[0], pixelPos[0]), (0, height), + style=item['linestyle'], + color=item['color'], + width=item['linewidth']) + lines.render(self.matScreenProj) + + else: + pixelPos = self._plot.dataToPixel( + xCoord, yCoord, axis=yAxis, check=True) + if pixelPos is None: + # Do not render markers outside visible plot area + continue + + if item['text'] is not None: x = pixelPos[0] + pixelOffset - y = self._plotFrame.margins.top + pixelOffset - label = Text2D(marker['text'], x, y, - color=marker['color'], + y = pixelPos[1] + pixelOffset + label = Text2D(item['text'], x, y, + color=item['color'], bgColor=(1., 1., 1., 0.5), align=LEFT, valign=TOP) labels.append(label) - height = self._plotFrame.size[1] - lines = GLLines2D((pixelPos[0], pixelPos[0]), (0, height), - style=marker['linestyle'], - color=marker['color'], - width=marker['linewidth']) - lines.render(self.matScreenProj) + # For now simple implementation: using a curve for each marker + # Should pack all markers to a single set of points + markerCurve = GLPlotCurve2D( + numpy.array((pixelPos[0],), dtype=numpy.float64), + numpy.array((pixelPos[1],), dtype=numpy.float64), + marker=item['symbol'], + markerColor=item['color'], + markerSize=11) + markerCurve.render(self.matScreenProj, False, False) + markerCurve.discard() 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((pixelPos[0],), dtype=numpy.float64), - numpy.array((pixelPos[1],), dtype=numpy.float64), - marker=marker['symbol'], - markerColor=marker['color'], - markerSize=11) - markerCurve.render(self.matScreenProj, False, False) - markerCurve.discard() - - gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + _logger.error('Unsupported item: %s', str(item)) + continue # Render marker labels + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) for label in labels: label.render(self.matScreenProj) - gl.glDisable(gl.GL_SCISSOR_TEST) - def _renderOverlayGL(self): - # Render crosshair cursor - if self._crosshairCursor is not None: - plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] + """Render overlay layer: overlay items and crosshair.""" + 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) - # Scissor to plot area - gl.glScissor(self._plotFrame.margins.left, - self._plotFrame.margins.bottom, - plotWidth, plotHeight) - gl.glEnable(gl.GL_SCISSOR_TEST) + self._renderItems(overlay=True) + # Render crosshair cursor + if self._crosshairCursor is not None and self._mousePosInPixels is not None: self._progBase.use() gl.glUniform2i(self._progBase.uniforms['isLog'], False, False) gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.) @@ -665,39 +623,39 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): colorUnif = self._progBase.uniforms['color'] hatchStepUnif = self._progBase.uniforms['hatchStep'] - # Render crosshair cursor in 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.astype(numpy.float32)) - - 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) + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + + gl.glUniformMatrix4fv(matrixUnif, 1, gl.GL_TRUE, + self.matScreenProj.astype(numpy.float32)) + + 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): + """Render base layer of plot area. + + It renders the background, grid and items except overlays + """ plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] gl.glScissor(self._plotFrame.margins.left, @@ -713,85 +671,9 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): # 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 - gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) - - for item in self._items.values(): - if ((isXLog and numpy.min(item['x']) < FLOAT32_MINPOS) or - (isYLog and numpy.min(item['y']) < FLOAT32_MINPOS)): - # Ignore items <= 0. on log axes - continue - - if item['shape'] == 'hline': - width = self._plotFrame.size[0] - _, yPixel = self.dataToPixel( - None, item['y'], axis='left', check=False) - points = numpy.array(((0., yPixel), (width, yPixel)), - dtype=numpy.float32) - - elif item['shape'] == 'vline': - xPixel, _ = self.dataToPixel( - item['x'], None, axis='left', check=False) - height = self._plotFrame.size[1] - points = numpy.array(((xPixel, 0), (xPixel, height)), - dtype=numpy.float32) - - else: - points = numpy.array([ - self.dataToPixel(x, y, axis='left', check=False) - for (x, y) in zip(item['x'], item['y'])]) - - # Draw the fill - if (item['fill'] is not None and - item['shape'] not in ('hline', 'vline')): - self._progBase.use() - gl.glUniformMatrix4fv( - self._progBase.uniforms['matrix'], 1, gl.GL_TRUE, - self.matScreenProj.astype(numpy.float32)) - gl.glUniform2i(self._progBase.uniforms['isLog'], False, False) - gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.) - - shape2D = FilledShape2D( - points, style=item['fill'], color=item['color']) - shape2D.render( - posAttrib=self._progBase.attributes['position'], - colorUnif=self._progBase.uniforms['color'], - hatchStepUnif=self._progBase.uniforms['hatchStep']) - - # Draw the stroke - if item['linestyle'] not in ('', ' ', None): - if item['shape'] != 'polylines': - # close the polyline - points = numpy.append(points, - numpy.atleast_2d(points[0]), axis=0) - - lines = GLLines2D(points[:, 0], points[:, 1], - style=item['linestyle'], - color=item['color'], - dash2ndColor=item['linebgcolor'], - width=item['linewidth']) - lines.render(self.matScreenProj) + if trBounds.x[0] != trBounds.x[1] and trBounds.y[0] != trBounds.y[1]: + # Do rendering of items + self._renderItems(overlay=False) gl.glDisable(gl.GL_SCISSOR_TEST) @@ -841,13 +723,13 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): else: raise ValueError('Unsupported data type') - def addCurve(self, x, y, legend, + def addCurve(self, x, y, 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): + xerror, yerror, z, + fill, alpha, symbolsize, baseline): + for parameter in (x, y, color, symbol, linewidth, linestyle, + yaxis, z, fill, symbolsize): assert parameter is not None assert yaxis in ('left', 'right') @@ -939,10 +821,9 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): if color is not None: color = color[0], color[1], color[2], color[3] * alpha - behaviors = set() - if selectable: - behaviors.add('selectable') - + fillColor = None + if fill is True: + fillColor = color curve = GLPlotCurve2D(x, y, colorArray, xError=xerror, yError=yerror, @@ -952,36 +833,24 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): marker=symbol, markerColor=color, markerSize=symbolsize, - fillColor=color if fill else None, + fillColor=fillColor, + baseline=baseline, isYLog=isYLog) 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 curve - return legend, 'curve' - - def addImage(self, data, legend, + def addImage(self, data, origin, scale, z, - selectable, draggable, colormap, alpha): - for parameter in (data, legend, origin, scale, z, - selectable, draggable): + for parameter in (data, origin, scale, z): 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): @@ -1002,12 +871,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): colormapIsLog, cmapRange, alpha) - image.info = { - 'legend': legend, - 'zOrder': z, - 'behaviors': behaviors - } - self._plotContent.add(image) elif len(data.shape) == 3: # For RGB, RGBA data @@ -1022,29 +885,21 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): 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' + # TODO is this needed? + 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') - def addTriangles(self, x, y, triangles, legend, - color, z, selectable, alpha): + return image + def addTriangles(self, x, y, triangles, + color, z, alpha): # Handle axes log scale: convert data if self._plotFrame.xAxis.isLog: x = numpy.log10(x) @@ -1052,31 +907,14 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): y = numpy.log10(y) triangles = GLPlotTriangles(x, y, color, triangles, alpha) - triangles.info = { - 'legend': legend, - 'zOrder': z, - 'behaviors': set(['selectable']) if selectable else set(), - } - self._plotContent.add(triangles) - return legend, 'triangles' + return triangles - def addItem(self, x, y, legend, shape, color, fill, overlay, z, + def addItem(self, x, y, shape, color, fill, overlay, z, linestyle, linewidth, linebgcolor): - # TODO handle overlay - if shape not in ('polygon', 'rectangle', 'line', - 'vline', 'hline', 'polylines'): - 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( @@ -1085,84 +923,35 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): raise RuntimeError( 'Cannot add item with Y <= 0 with Y axis log scale') - # Ignore fill for polylines to mimic matplotlib - fill = fill if shape != 'polylines' else False - - self._items[legend] = { - 'shape': shape, - 'color': colors.rgba(color), - 'fill': 'hatch' if fill else None, - 'x': x, - 'y': y, - 'linestyle': linestyle, - 'linewidth': linewidth, - 'linebgcolor': linebgcolor, - } - - return legend, 'item' - - def addMarker(self, x, y, legend, text, color, - selectable, draggable, - symbol, linestyle, linewidth, constraint): - - 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) - - 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, - 'linestyle': linestyle, - 'linewidth': linewidth, - } + return _ShapeItem(x, y, shape, color, fill, overlay, z, + linestyle, linewidth, linebgcolor) - return legend, 'marker' + def addMarker(self, x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis): + return _MarkerItem(x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis) # Remove methods def remove(self, item): - legend, kind = item - - if kind == 'curve': - curve = self._plotContent.pop('curve', legend) - if curve is not None: + if isinstance(item, (GLPlotCurve2D, + GLPlotColormap, + GLPlotRGBAImage, + GLPlotTriangles)): + if isinstance(item, GLPlotCurve2D): # 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') + y2AxisItems = (item for item in self._plot.getItems() + if isinstance(item, items.YAxisMixIn) and + item.getYAxis() == 'right') self._plotFrame.isY2Axis = next(y2AxisItems, None) is not None - self._glGarbageCollector.append(curve) - - elif kind in ('image', 'triangles'): - item = self._plotContent.pop(kind, legend) - if item is not None: - self._glGarbageCollector.append(item) + self._glGarbageCollector.append(item) - elif kind == 'marker': - self._markers.pop(legend, False) - - elif kind == 'item': - self._items.pop(legend, False) + elif isinstance(item, (_MarkerItem, _ShapeItem)): + pass # No-op else: - _logger.error('Unsupported kind: %s', str(kind)) + _logger.error('Unsupported item: %s', str(item)) # Interaction methods @@ -1212,8 +1001,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): :param GLPlotCurve2D item: :param float x: X position of the mouse in widget coordinates :param float y: Y position of the mouse in widget coordinates - :return: List of indices of picked points - :rtype: List[int] + :return: List of indices of picked points or None if not picked + :rtype: Union[List[int],None] """ offset = self._PICK_OFFSET if item.marker is not None: @@ -1224,17 +1013,17 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): yAxis = item.info['yAxis'] inAreaPos = self._mouseInPlotArea(x - offset, y - offset) - dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1], - axis=yAxis, check=True) + dataPos = self._plot.pixelToData(inAreaPos[0], inAreaPos[1], + axis=yAxis, check=True) if dataPos is None: - return [] + return None xPick0, yPick0 = dataPos inAreaPos = self._mouseInPlotArea(x + offset, y + offset) - dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1], - axis=yAxis, check=True) + dataPos = self._plot.pixelToData(inAreaPos[0], inAreaPos[1], + axis=yAxis, check=True) if dataPos is None: - return [] + return None xPick1, yPick1 = dataPos if xPick0 < xPick1: @@ -1260,69 +1049,58 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): return item.pick(xPickMin, yPickMin, xPickMax, yPickMax) - def pickItems(self, x, y, kinds): - picked = [] - - dataPos = self.pixelToData(x, y, axis='left', check=True) - if dataPos is not None: - # Pick markers - if 'marker' in kinds: - 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 + def pickItem(self, x, y, item): + dataPos = self._plot.pixelToData(x, y, axis='left', check=True) + if dataPos is None: + return None # Outside plot area + + if item is None: + _logger.error("No item provided for picking") + return None + + # Pick markers + if isinstance(item, _MarkerItem): + yaxis = item['yaxis'] + pixelPos = self._plot.dataToPixel( + item['x'], item['y'], axis=yaxis, check=False) + if pixelPos is None: + return None # negative coord on a log axis + + if item['x'] is None: # Horizontal line + pt1 = self._plot.pixelToData( + x, y - self._PICK_OFFSET, axis=yaxis, check=False) + pt2 = self._plot.pixelToData( + x, y + self._PICK_OFFSET, axis=yaxis, check=False) + isPicked = (min(pt1[1], pt2[1]) <= item['y'] <= + max(pt1[1], pt2[1])) + + elif item['y'] is None: # Vertical line + pt1 = self._plot.pixelToData( + x - self._PICK_OFFSET, y, axis=yaxis, check=False) + pt2 = self._plot.pixelToData( + x + self._PICK_OFFSET, y, axis=yaxis, check=False) + isPicked = (min(pt1[0], pt2[0]) <= item['x'] <= + max(pt1[0], pt2[0])) - 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 - if 'image' in kinds or 'curve' in kinds: - for item in self._plotContent.zOrderedPrimitives(reverse=True): - if ('image' in kinds and - isinstance(item, (GLPlotColormap, GLPlotRGBAImage))): - pickedPos = item.pick(*dataPos) - if pickedPos is not None: - picked.append(dict(kind='image', - legend=item.info['legend'])) - - elif 'curve' in kinds: - if isinstance(item, GLPlotCurve2D): - pickedIndices = self.__pickCurves(item, x, y) - if pickedIndices: - picked.append(dict(kind='curve', - legend=item.info['legend'], - indices=pickedIndices)) - - elif isinstance(item, GLPlotTriangles): - pickedIndices = item.pick(*dataPos) - if pickedIndices: - picked.append(dict(kind='curve', - legend=item.info['legend'], - indices=pickedIndices)) - return picked + else: + isPicked = ( + numpy.fabs(x - pixelPos[0]) <= self._PICK_OFFSET and + numpy.fabs(y - pixelPos[1]) <= self._PICK_OFFSET) + + return (0,) if isPicked else None + + # Pick image, curve, triangles + elif isinstance(item, (GLPlotCurve2D, + GLPlotColormap, + GLPlotRGBAImage, + GLPlotTriangles)): + if isinstance(item, (GLPlotColormap, GLPlotRGBAImage, GLPlotTriangles)): + return item.pick(*dataPos) # Might be None + + elif isinstance(item, GLPlotCurve2D): + return self.__pickCurves(item, x, y) + else: + return None # Update curve @@ -1426,12 +1204,11 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): 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_) - + ranges = self._plot.getDataRange() + if (ranges.y is not None and + ranges.x is not None and + (ranges.y[1] - ranges.y[0]) != 0.): + dataRatio = (ranges.x[1] - ranges.x[0]) / float(ranges.y[1] - ranges.y[0]) plotRatio = plotWidth / float(plotHeight) # Test != 0 before keepDim = 'x' if dataRatio > plotRatio else 'y' @@ -1564,51 +1341,10 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): # 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 + def dataToPixel(self, x, y, axis): + return self._plotFrame.dataToPixel(x, y, axis) + def pixelToData(self, x, y, axis): return self._plotFrame.pixelToData(x, y, axis) def getPlotBoundsInPixels(self): diff --git a/silx/gui/plot/backends/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py index 5f8d652..3a0ebac 100644 --- a/silx/gui/plot/backends/glutils/GLPlotCurve.py +++ b/silx/gui/plot/backends/glutils/GLPlotCurve.py @@ -132,8 +132,7 @@ class _Fill2D(object): self.xData is not None and self.yData is not None): # Get slices of not NaN values longer than 1 element - isnan = numpy.logical_or(numpy.isnan(self.xData), - numpy.isnan(self.yData)) + isnan = numpy.logical_or(numpy.isnan(self.xData), numpy.isnan(self.yData)) notnan = numpy.logical_not(isnan) start = numpy.where(numpy.logical_and(isnan[:-1], notnan[1:]))[0] + 1 if notnan[0]: @@ -147,22 +146,25 @@ class _Fill2D(object): # Number of points: slice + 2 * leading and trailing points # Twice leading and trailing points to produce degenerated triangles - nbPoints = numpy.sum(numpy.diff(slices, axis=1)) + 4 * len(slices) + nbPoints = numpy.sum(numpy.diff(slices, axis=1)) * 2 + 4 * len(slices) points = numpy.empty((nbPoints, 2), dtype=numpy.float32) offset = 0 + # invert baseline for filling + new_y_data = numpy.append(self.yData, self.baseline) for start, end in slices: # Duplicate first point for connecting degenerated triangle - points[offset:offset+2] = self.xData[start], self.baseline + points[offset:offset+2] = self.xData[start], new_y_data[start] # 2nd point of the polygon is last point - points[offset+2] = self.xData[end-1], self.baseline + points[offset+2] = self.xData[start], self.baseline[start] - # Add all points from the data - indices = start + buildFillMaskIndices(end - start) + indices = numpy.append(numpy.arange(start, end), + numpy.arange(len(self.xData) + end-1, len(self.xData) + start-1, -1)) + indices = indices[buildFillMaskIndices(len(indices))] - points[offset+3:offset+3+len(indices), 0] = self.xData[indices] - points[offset+3:offset+3+len(indices), 1] = self.yData[indices] + points[offset+3:offset+3+len(indices), 0] = self.xData[indices % len(self.xData)] + points[offset+3:offset+3+len(indices), 1] = new_y_data[indices] # Duplicate last point for connecting degenerated triangle points[offset+3+len(indices)] = points[offset+3+len(indices)-1] @@ -526,7 +528,16 @@ def distancesFromArrays(xData, yData): DIAMOND, CIRCLE, SQUARE, PLUS, X_MARKER, POINT, PIXEL, ASTERISK = \ 'd', 'o', 's', '+', 'x', '.', ',', '*' -H_LINE, V_LINE = '_', '|' +H_LINE, V_LINE, HEART = '_', '|', u'\u2665' + +TICK_LEFT = "tickleft" +TICK_RIGHT = "tickright" +TICK_UP = "tickup" +TICK_DOWN = "tickdown" +CARET_LEFT = "caretleft" +CARET_RIGHT = "caretright" +CARET_UP = "caretup" +CARET_DOWN = "caretdown" class _Points2D(object): @@ -542,7 +553,8 @@ class _Points2D(object): """ MARKERS = (DIAMOND, CIRCLE, SQUARE, PLUS, X_MARKER, POINT, PIXEL, ASTERISK, - H_LINE, V_LINE) + H_LINE, V_LINE, HEART, TICK_LEFT, TICK_RIGHT, TICK_UP, TICK_DOWN, + CARET_LEFT, CARET_RIGHT, CARET_UP, CARET_DOWN) """List of supported markers""" _VERTEX_SHADER = """ @@ -640,7 +652,110 @@ class _Points2D(object): return 0.0; } } - """ + """, + HEART: """ + float alphaSymbol(vec2 coord, float size) { + coord = (coord - 0.5) * 2.; + coord *= 0.75; + coord.y += 0.25; + float a = atan(coord.x,-coord.y)/3.141593; + float r = length(coord); + float h = abs(a); + float d = (13.0*h - 22.0*h*h + 10.0*h*h*h)/(6.0-5.0*h); + float res = clamp(r-d, 0., 1.); + // antialiasing + res = smoothstep(0.1, 0.001, res); + return res; + } + """, + TICK_LEFT: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dy = abs(coord.y); + if (dy < 0.5 && coord.x < 0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + TICK_RIGHT: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dy = abs(coord.y); + if (dy < 0.5 && coord.x > -0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + TICK_UP: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dx = abs(coord.x); + if (dx < 0.5 && coord.y < 0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + TICK_DOWN: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dx = abs(coord.x); + if (dx < 0.5 && coord.y > -0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + CARET_LEFT: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float d = abs(coord.x) - abs(coord.y); + if (d >= -0.1 && coord.x > 0.5) { + return smoothstep(-0.1, 0.1, d); + } else { + return 0.0; + } + } + """, + CARET_RIGHT: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float d = abs(coord.x) - abs(coord.y); + if (d >= -0.1 && coord.x < 0.5) { + return smoothstep(-0.1, 0.1, d); + } else { + return 0.0; + } + } + """, + CARET_UP: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float d = abs(coord.y) - abs(coord.x); + if (d >= -0.1 && coord.y > 0.5) { + return smoothstep(-0.1, 0.1, d); + } else { + return 0.0; + } + } + """, + CARET_DOWN: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float d = abs(coord.y) - abs(coord.x); + if (d >= -0.1 && coord.y < 0.5) { + return smoothstep(-0.1, 0.1, d); + } else { + return 0.0; + } + } + """, } _FRAGMENT_SHADER_TEMPLATE = """ @@ -964,6 +1079,7 @@ class GLPlotCurve2D(object): markerColor=(0., 0., 0., 1.), markerSize=7, fillColor=None, + baseline=None, isYLog=False): self.colorData = colorData @@ -1003,11 +1119,28 @@ class GLPlotCurve2D(object): self.offset = 0., 0. self.xData = xData self.yData = yData - if fillColor is not None: + def deduce_baseline(baseline): + if baseline is None: + _baseline = 0 + else: + _baseline = baseline + if not isinstance(_baseline, numpy.ndarray): + _baseline = numpy.repeat(_baseline, + len(self.xData)) + if isYLog is True: + with warnings.catch_warnings(): # Ignore NaN comparison warnings + warnings.simplefilter('ignore', + category=RuntimeWarning) + log_val = numpy.log10(_baseline) + _baseline = numpy.where(_baseline>0.0, log_val, -38) + return _baseline + + _baseline = deduce_baseline(baseline) + # Use different baseline depending of Y log scale self.fill = _Fill2D(self.xData, self.yData, - baseline=-38 if isYLog else 0, + baseline=_baseline, color=fillColor, offset=self.offset) else: @@ -1129,7 +1262,7 @@ class GLPlotCurve2D(object): and the segment [i-1, i] is not tested for picking. :return: The indices of the picked data - :rtype: list of int + :rtype: Union[List[int],None] """ if (self.marker is None and self.lineStyle is None) or \ self.xMin > xPickMax or xPickMin > self.xMax or \ @@ -1209,4 +1342,4 @@ class GLPlotCurve2D(object): (self.yData >= yPickMin) & (self.yData <= yPickMax))[0].tolist() - return indices + return tuple(indices) if len(indices) > 0 else None diff --git a/silx/gui/plot/backends/glutils/GLPlotImage.py b/silx/gui/plot/backends/glutils/GLPlotImage.py index 6f3c487..5d79023 100644 --- a/silx/gui/plot/backends/glutils/GLPlotImage.py +++ b/silx/gui/plot/backends/glutils/GLPlotImage.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2018 European Synchrotron Radiation Facility +# Copyright (c) 2014-2019 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 @@ -56,7 +56,7 @@ class _GLPlotData2D(object): sx, sy = self.scale col = int((x - ox) / sx) row = int((y - oy) / sy) - return col, row + return ((row, col),) else: return None diff --git a/silx/gui/plot/backends/glutils/GLPlotTriangles.py b/silx/gui/plot/backends/glutils/GLPlotTriangles.py index c756749..7aeb5ab 100644 --- a/silx/gui/plot/backends/glutils/GLPlotTriangles.py +++ b/silx/gui/plot/backends/glutils/GLPlotTriangles.py @@ -114,11 +114,12 @@ class GLPlotTriangles(object): :param float x: X coordinates in plot data frame :param float y: Y coordinates in plot data frame :return: List of picked data point indices - :rtype: numpy.ndarray + :rtype: Union[List[int],None] """ if (x < self.xMin or x > self.xMax or y < self.yMin or y > self.yMax): - return () + return None + xPts, yPts = self.__x_y_color[:2] if self.__picking_triangles is None: self.__picking_triangles = numpy.zeros( @@ -135,9 +136,9 @@ class GLPlotTriangles(object): # Sorted from furthest to closest point dists = (xPts[indices] - x) ** 2 + (yPts[indices] - y) ** 2 - indices = indices[numpy.flip(numpy.argsort(dists))] + indices = indices[numpy.flip(numpy.argsort(dists), axis=0)] - return tuple(indices) + return tuple(indices) if len(indices) > 0 else None def discard(self): """Release resources on the GPU""" |