diff options
Diffstat (limited to 'src/silx/gui/plot/_utils/panzoom.py')
-rw-r--r-- | src/silx/gui/plot/_utils/panzoom.py | 325 |
1 files changed, 325 insertions, 0 deletions
diff --git a/src/silx/gui/plot/_utils/panzoom.py b/src/silx/gui/plot/_utils/panzoom.py new file mode 100644 index 0000000..77efd10 --- /dev/null +++ b/src/silx/gui/plot/_utils/panzoom.py @@ -0,0 +1,325 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2021 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. +# +# ###########################################################################*/ +"""Functions to apply pan and zoom on a Plot""" + +__authors__ = ["T. Vincent", "V. Valls"] +__license__ = "MIT" +__date__ = "08/08/2017" + + +import logging +import math +import numpy + + +_logger = logging.getLogger(__name__) + + +# Float 32 info ############################################################### +# Using min/max value below limits of float32 +# so operation with such value (e.g., max - min) do not overflow + +FLOAT32_SAFE_MIN = -1e37 +FLOAT32_MINPOS = numpy.finfo(numpy.float32).tiny +FLOAT32_SAFE_MAX = 1e37 +# TODO double support + + +def checkAxisLimits(vmin, vmax, isLog: bool=False, name: str=""): + """Makes sure axis range is not empty and within supported range. + + :param float vmin: Min axis value + :param float vmax: Max axis value + :return: (min, max) making sure min < max + :rtype: 2-tuple of float + """ + min_ = FLOAT32_MINPOS if isLog else FLOAT32_SAFE_MIN + vmax = numpy.clip(vmax, min_, FLOAT32_SAFE_MAX) + vmin = numpy.clip(vmin, min_, FLOAT32_SAFE_MAX) + + if vmax < vmin: + _logger.debug('%s axis: max < min, inverting limits.', name) + vmin, vmax = vmax, vmin + elif vmax == vmin: + _logger.debug('%s axis: max == min, expanding limits.', name) + if vmin == 0.: + vmin, vmax = -0.1, 0.1 + elif vmin < 0: + vmax *= 0.9 + vmin = max(vmin * 1.1, FLOAT32_SAFE_MIN) # Clip to range + else: # vmin > 0 + vmax = min(vmin * 1.1, FLOAT32_SAFE_MAX) # Clip to range + vmin *= 0.9 + + return vmin, vmax + + +def scale1DRange(min_, max_, center, scale, isLog): + """Scale a 1D range given a scale factor and an center point. + + Keeps the values in a smaller range than float32. + + :param float min_: The current min value of the range. + :param float max_: The current max value of the range. + :param float center: The center of the zoom (i.e., invariant point). + :param float scale: The scale to use for zoom + :param bool isLog: Whether using log scale or not. + :return: The zoomed range. + :rtype: tuple of 2 floats: (min, max) + """ + if isLog: + # Min and center can be < 0 when + # autoscale is off and switch to log scale + # max_ < 0 should not happen + min_ = numpy.log10(min_) if min_ > 0. else FLOAT32_MINPOS + center = numpy.log10(center) if center > 0. else FLOAT32_MINPOS + max_ = numpy.log10(max_) if max_ > 0. else FLOAT32_MINPOS + + if min_ == max_: + return min_, max_ + + offset = (center - min_) / (max_ - min_) + range_ = (max_ - min_) / scale + newMin = center - offset * range_ + newMax = center + (1. - offset) * range_ + + if isLog: + # No overflow as exponent is log10 of a float32 + newMin = pow(10., newMin) + newMax = pow(10., newMax) + newMin = numpy.clip(newMin, FLOAT32_MINPOS, FLOAT32_SAFE_MAX) + newMax = numpy.clip(newMax, FLOAT32_MINPOS, FLOAT32_SAFE_MAX) + else: + newMin = numpy.clip(newMin, FLOAT32_SAFE_MIN, FLOAT32_SAFE_MAX) + newMax = numpy.clip(newMax, FLOAT32_SAFE_MIN, FLOAT32_SAFE_MAX) + return newMin, newMax + + +def applyZoomToPlot(plot, scaleF, center=None): + """Zoom in/out plot given a scale and a center point. + + :param plot: The plot on which to apply zoom. + :param float scaleF: Scale factor of zoom. + :param center: (x, y) coords in pixel coordinates of the zoom center. + :type center: 2-tuple of float + """ + xMin, xMax = plot.getXAxis().getLimits() + yMin, yMax = plot.getYAxis().getLimits() + + if center is None: + left, top, width, height = plot.getPlotBoundsInPixels() + cx, cy = left + width // 2, top + height // 2 + else: + cx, cy = center + + dataCenterPos = plot.pixelToData(cx, cy) + assert dataCenterPos is not None + + xMin, xMax = scale1DRange(xMin, xMax, dataCenterPos[0], scaleF, + plot.getXAxis()._isLogarithmic()) + + yMin, yMax = scale1DRange(yMin, yMax, dataCenterPos[1], scaleF, + plot.getYAxis()._isLogarithmic()) + + dataPos = plot.pixelToData(cx, cy, axis="right") + assert dataPos is not None + y2Center = dataPos[1] + y2Min, y2Max = plot.getYAxis(axis="right").getLimits() + y2Min, y2Max = scale1DRange(y2Min, y2Max, y2Center, scaleF, + plot.getYAxis()._isLogarithmic()) + + plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max) + + +def applyPan(min_, max_, panFactor, isLog10): + """Returns a new range with applied panning. + + Moves the range according to panFactor. + If isLog10 is True, converts to log10 before moving. + + :param float min_: Min value of the data range to pan. + :param float max_: Max value of the data range to pan. + Must be >= min. + :param float panFactor: Signed proportion of the range to use for pan. + :param bool isLog10: True if log10 scale, False if linear scale. + :return: New min and max value with pan applied. + :rtype: 2-tuple of float. + """ + if isLog10 and min_ > 0.: + # Negative range and log scale can happen with matplotlib + logMin, logMax = math.log10(min_), math.log10(max_) + logOffset = panFactor * (logMax - logMin) + newMin = pow(10., logMin + logOffset) + newMax = pow(10., logMax + logOffset) + + # Takes care of out-of-range values + if newMin > 0. and newMax < float('inf'): + min_, max_ = newMin, newMax + + else: + offset = panFactor * (max_ - min_) + newMin, newMax = min_ + offset, max_ + offset + + # Takes care of out-of-range values + if newMin > - float('inf') and newMax < float('inf'): + min_, max_ = newMin, newMax + return min_, max_ + + +class _Unset(object): + """To be able to have distinction between None and unset""" + pass + + +class ViewConstraints(object): + """ + Store constraints applied on the view box and compute the resulting view box. + """ + + def __init__(self): + self._min = [None, None] + self._max = [None, None] + self._minRange = [None, None] + self._maxRange = [None, None] + + def update(self, xMin=_Unset, xMax=_Unset, + yMin=_Unset, yMax=_Unset, + minXRange=_Unset, maxXRange=_Unset, + minYRange=_Unset, maxYRange=_Unset): + """ + Update the constraints managed by the object + + The constraints are the same as the ones provided by PyQtGraph. + + :param float xMin: Minimum allowed x-axis value. + (default do not change the stat, None remove the constraint) + :param float xMax: Maximum allowed x-axis value. + (default do not change the stat, None remove the constraint) + :param float yMin: Minimum allowed y-axis value. + (default do not change the stat, None remove the constraint) + :param float yMax: Maximum allowed y-axis value. + (default do not change the stat, None remove the constraint) + :param float minXRange: Minimum allowed left-to-right span across the + view (default do not change the stat, None remove the constraint) + :param float maxXRange: Maximum allowed left-to-right span across the + view (default do not change the stat, None remove the constraint) + :param float minYRange: Minimum allowed top-to-bottom span across the + view (default do not change the stat, None remove the constraint) + :param float maxYRange: Maximum allowed top-to-bottom span across the + view (default do not change the stat, None remove the constraint) + :return: True if the constraints was changed + """ + updated = False + + minRange = [minXRange, minYRange] + maxRange = [maxXRange, maxYRange] + minPos = [xMin, yMin] + maxPos = [xMax, yMax] + + for axis in range(2): + + value = minPos[axis] + if value is not _Unset and value != self._min[axis]: + self._min[axis] = value + updated = True + + value = maxPos[axis] + if value is not _Unset and value != self._max[axis]: + self._max[axis] = value + updated = True + + value = minRange[axis] + if value is not _Unset and value != self._minRange[axis]: + self._minRange[axis] = value + updated = True + + value = maxRange[axis] + if value is not _Unset and value != self._maxRange[axis]: + self._maxRange[axis] = value + updated = True + + # Sanity checks + + for axis in range(2): + if self._maxRange[axis] is not None and self._min[axis] is not None and self._max[axis] is not None: + # max range cannot be larger than bounds + diff = self._max[axis] - self._min[axis] + self._maxRange[axis] = min(self._maxRange[axis], diff) + updated = True + + return updated + + def normalize(self, xMin, xMax, yMin, yMax, allow_scaling=True): + """Normalize a view range defined by x and y corners using predefined + containts. + + :param float xMin: Min position of the x-axis + :param float xMax: Max position of the x-axis + :param float yMin: Min position of the y-axis + :param float yMax: Max position of the y-axis + :param bool allow_scaling: Allow or not to apply scaling for the + normalization. Used according to the interaction mode. + :return: A normalized tuple of (xMin, xMax, yMin, yMax) + """ + viewRange = [[xMin, xMax], [yMin, yMax]] + + for axis in range(2): + # clamp xRange and yRange + if allow_scaling: + diff = viewRange[axis][1] - viewRange[axis][0] + delta = None + if self._maxRange[axis] is not None and diff > self._maxRange[axis]: + delta = self._maxRange[axis] - diff + elif self._minRange[axis] is not None and diff < self._minRange[axis]: + delta = self._minRange[axis] - diff + if delta is not None: + viewRange[axis][0] -= delta * 0.5 + viewRange[axis][1] += delta * 0.5 + + # clamp min and max positions + outMin = self._min[axis] is not None and viewRange[axis][0] < self._min[axis] + outMax = self._max[axis] is not None and viewRange[axis][1] > self._max[axis] + + if outMin and outMax: + if allow_scaling: + # we can clamp both sides + viewRange[axis][0] = self._min[axis] + viewRange[axis][1] = self._max[axis] + else: + # center the result + delta = viewRange[axis][1] - viewRange[axis][0] + mid = self._min[axis] + self._max[axis] - self._min[axis] + viewRange[axis][0] = mid - delta + viewRange[axis][1] = mid + delta + elif outMin: + delta = self._min[axis] - viewRange[axis][0] + viewRange[axis][0] += delta + viewRange[axis][1] += delta + elif outMax: + delta = self._max[axis] - viewRange[axis][1] + viewRange[axis][0] += delta + viewRange[axis][1] += delta + + return viewRange[0][0], viewRange[0][1], viewRange[1][0], viewRange[1][1] |