diff --git a/examples/testAnimatedAxis.py b/examples/testAnimatedAxis.py new file mode 100644 index 0000000000..aa29b2a025 --- /dev/null +++ b/examples/testAnimatedAxis.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-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 +# 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. +# +# ###########################################################################*/ +"""Example demonstrating animated axis""" + +import numpy +from silx.gui import qt +from silx.gui.plot import Plot1D +from silx.gui.plot.actions import control as control_actions + + +class TestAnimatedAxes(Plot1D): + def __init__(self, parent=None): + super(TestAnimatedAxes, self).__init__(parent=parent) + self._createActions() + self._i = 0 + self._timer = qt.QTimer(self) + self._timer.timeout.connect(self._tick) + self._toggleSequence() + + toolbar = qt.QToolBar(self) + toolbar.addAction(control_actions.OpenGLAction(parent=toolbar, plot=self)) + self.addToolBar(toolbar) + + + def _createActions(self): + action = qt.QAction(self) + action.setText("Start/stop sequence") + action.triggered.connect(self._toggleSequence) + action.setShortcut(" ") + self.addAction(action) + + action = qt.QAction(self) + action.setText("Create/remove curve1") + action.triggered.connect(self._toggleCurve1) + action.setShortcut("1") + self.addAction(action) + + action = qt.QAction(self) + action.setText("Create/remove curve1") + action.triggered.connect(self._toggleCurve2) + action.setShortcut("2") + self.addAction(action) + + action = qt.QAction(self) + action.setText("Create/remove curve1") + action.triggered.connect(self._toggleCurve3) + action.setShortcut("3") + self.addAction(action) + + action = qt.QAction(self) + action.setText("Create/remove curve1") + action.triggered.connect(self._toggleCurve4) + action.setShortcut("4") + self.addAction(action) + + action = qt.QAction(self) + action.setText("Create/remove curve5") + action.triggered.connect(self._toggleCurve5) + action.setShortcut("5") + self.addAction(action) + + def _executeCommand(self, command): + sequence = qt.QKeySequence(command) + for action in self.actions(): + if action.shortcut() == sequence: + action.trigger() + + def _tick(self): + sequence = "12531415" + command = sequence[self._i % len(sequence)] + self._executeCommand(command) + self._i += 1 + + def _toggleSequence(self): + if self._timer.isActive(): + self._timer.stop() + else: + self._timer.start(2000) + + def _toggleCurve1(self): + legend = "curve1" + curve = self.getCurve(legend) + if curve is None: + xx = numpy.arange(0, 50) + yy = numpy.sin(xx) + 2 + self.addCurve(xx, yy, legend=legend) + else: + self.removeCurve(legend) + self.resetZoom() + + def _toggleCurve2(self): + legend = "curve2" + curve = self.getCurve(legend) + if curve is None: + xx = numpy.arange(100, 200) + yy = numpy.sin(xx) + 3 + self.addCurve(xx, yy, legend=legend) + else: + self.removeCurve(legend) + self.resetZoom() + + def _toggleCurve3(self): + legend = "curve3" + curve = self.getCurve(legend) + if curve is None: + xx = numpy.arange(10, 100) + yy = numpy.sin(xx) + -1 + self.addCurve(xx, yy, legend=legend) + else: + self.removeCurve(legend) + self.resetZoom() + + def _toggleCurve4(self): + legend = "curve4" + curve = self.getCurve(legend) + if curve is None: + xx = numpy.arange(110, 130) + yy = numpy.sin(xx) + 1.5 + self.addCurve(xx, yy, legend=legend, yaxis="right") + else: + self.removeCurve(legend) + self.resetZoom() + + def _toggleCurve5(self): + legend = "curve5" + curve = self.getCurve(legend) + if curve is None: + xx = numpy.arange(-10, 30) + yy = numpy.sin(xx) + 1.5 + self.addCurve(xx, yy, legend=legend, yaxis="right") + else: + self.removeCurve(legend) + self.resetZoom() + + +def main(): + app = qt.QApplication([]) + plot = TestAnimatedAxes() + plot.getView().setAnimated(True) + plot.show() + return app.exec_() + + +if __name__ == "__main__": + main() diff --git a/src/silx/gui/plot/PlotWidget.py b/src/silx/gui/plot/PlotWidget.py index 5f050f93f2..5d7a26453e 100755 --- a/src/silx/gui/plot/PlotWidget.py +++ b/src/silx/gui/plot/PlotWidget.py @@ -65,6 +65,7 @@ from . import items from .items.curve import CurveStyle from .items.axis import TickMode # noqa +from .items.axis import View from .. import qt from ._utils.panzoom import ViewConstraints @@ -370,6 +371,7 @@ def __init__(self, parent=None, backend=None): self.__muteActiveItemChanged = False self._panWithArrowKeys = True + self._view = None self._viewConstrains = None super(PlotWidget, self).__init__(parent) @@ -635,6 +637,15 @@ def getBackend(self): """ return self._backend + def getView(self): + """Returns an object that represent the data view visualized by the plot. + + :rtype: View + """ + if self._view is None: + self._view = View(self) + return self._view + def _getDirtyPlot(self): """Return the plot dirty flag. @@ -879,7 +890,11 @@ def getDataRange(self): """ if self._dataRange is None: self._updateDataRange() - return self._dataRange + + dataRange = self._dataRange + if self._view is not None: + dataRange = self._view.updateDataRange(dataRange) + return dataRange # Content management @@ -3156,6 +3171,11 @@ def _forceResetZoom( if ranges.y is None: ymin, ymax = ranges.yright + if ymin is None and ymax is None: + # FIXME: Workaround y1 can be None if y2 contains something + ymin = 0 + ymax = 1 + self.setLimits( xmin, xmax, diff --git a/src/silx/gui/plot/items/axis.py b/src/silx/gui/plot/items/axis.py index 7b36b2da81..f33dc0cabe 100644 --- a/src/silx/gui/plot/items/axis.py +++ b/src/silx/gui/plot/items/axis.py @@ -31,8 +31,12 @@ __date__ = "22/11/2018" import datetime as dt +import time import enum from typing import Optional +import numpy +import weakref +import collections import dateutil.tz @@ -47,6 +51,137 @@ class TickMode(enum.Enum): TIME_SERIES = 1 # Ticks are datetime objects +def _bezier_interpolation(t): + """Returns the value of a specific Bezier transfer function to compute + acceleration then desceleration. + + The input is supposed to be between [0..1], but the result is clamped. + The result for an input smaller 0.0 is 0.0 and the result for an input + highter than 1.0 is 1.0 + """ + if t < 0: + return 0 + elif t < .5: + return 4 * t ** 3 + elif t < 1.0: + return (t - 1) * (2 * t - 2) ** 2 + 1 + else: + return 1.0 + + +class View(object): + + INTERPOLATION_DURATION = 500 + """Duration of the interpolation animation, in millisecond""" + + REFRESH_PERIOD = 50 + """Period used to referesh the animation""" + + def __init__(self, plot): + self.__plot = weakref.ref(plot) + self.__isAnimated = False + self.__computedRange = None + self.__targetedDataRange = None + self.__timer = qt.QTimer(plot) + self.__timer.timeout.connect(self.__tick) + + def getPlot(self): + return self.__plot() + + def setAnimated(self, isAnimated): + isAnimated = bool(isAnimated) + if self.__isAnimated == isAnimated: + return + self.__isAnimated = isAnimated + if isAnimated and self.__computedRange is not None: + self.__interruptAnimation() + + def __interruptAnimation(self): + self.__computedRange = None + self.__startTime = None + self.__timer.stop() + plot = self.getPlot() + plot.resetZoom() + + def dataRangeToArray(self, dataRange): + normalized = numpy.zeros(6) + normalized[0:2] = dataRange.x + normalized[2:4] = dataRange.y + normalized[4:6] = dataRange.yright + return normalized + + RangeType = collections.namedtuple("RangeType", ["x", "y", "yright"]) + + def arrayToRange(self, data): + vmin = data[0] if not numpy.isnan(data[0]) else None + vmax = data[1] if not numpy.isnan(data[1]) else None + return vmin, vmax + + def arrayToDataRange(self, array): + return self.RangeType( + self.arrayToRange(array[0:2]), + self.arrayToRange(array[2:4]), + self.arrayToRange(array[4:6]), + ) + + def __updateSmoothing(self, newRange): + self.__startTime = time.time() + if self.__computedRange is None: + self.__start = self.dataRangeToArray(self.__targetedDataRange) + self.__stop = self.dataRangeToArray(newRange) + self.__timer.start(self.REFRESH_PERIOD) + else: + self.__start = self.dataRangeToArray(self.__computedRange) + self.__stop = self.dataRangeToArray(newRange) + + # Be conservative during the animation + nanstop = numpy.isnan(self.__stop) + nanstart = numpy.isnan(self.__start) + self.__stop[nanstop] = self.__start[nanstop] + self.__start[nanstart] = self.__stop[nanstart] + + def __tick(self): + coef = ((time.time() - self.__startTime) * 1000) / self.INTERPOLATION_DURATION + if coef >= 1.0: + self.__interruptAnimation() + return + + # Can be cached + coef = _bezier_interpolation(coef) + dataRange = self.__start * (1 - coef) + self.__stop * coef + self.__computedRange = self.arrayToDataRange(dataRange) + plot = self.getPlot() + plot.resetZoom() + + def updateDataRange(self, dataRange): + """ + Returns this PlotWidget's data range. + + :return: a namedtuple with the following members: + x, y (left y axis), yright. Each member is a tuple (min, max) + or None if no data is associated with the axis. + :rtype: namedtuple + """ + if not self.__isAnimated: + return dataRange + + if self.__targetedDataRange is None: + # Initial value + self.__targetedDataRange = dataRange + if self.__targetedDataRange == dataRange: + if self.__computedRange is not None: + return self.__computedRange + else: + return dataRange + else: + if dataRange.x is None and dataRange.y is None and dataRange.yright is None: + return dataRange + self.__updateSmoothing(dataRange) + tmp = self.__targetedDataRange + self.__targetedDataRange = dataRange + return tmp + + class Axis(qt.QObject): """This class describes and controls a plot axis.