diff options
Diffstat (limited to 'src/silx/gui/plot/_utils')
-rw-r--r-- | src/silx/gui/plot/_utils/__init__.py | 28 | ||||
-rw-r--r-- | src/silx/gui/plot/_utils/delaunay.py | 62 | ||||
-rw-r--r-- | src/silx/gui/plot/_utils/dtime_ticklayout.py | 180 | ||||
-rw-r--r-- | src/silx/gui/plot/_utils/panzoom.py | 137 | ||||
-rw-r--r-- | src/silx/gui/plot/_utils/setup.py | 42 | ||||
-rw-r--r-- | src/silx/gui/plot/_utils/test/__init__.py | 1 | ||||
-rw-r--r-- | src/silx/gui/plot/_utils/test/test_dtime_ticklayout.py | 80 | ||||
-rw-r--r-- | src/silx/gui/plot/_utils/test/test_ticklayout.py | 22 | ||||
-rw-r--r-- | src/silx/gui/plot/_utils/ticklayout.py | 19 |
9 files changed, 274 insertions, 297 deletions
diff --git a/src/silx/gui/plot/_utils/__init__.py b/src/silx/gui/plot/_utils/__init__.py index ed87b18..3075007 100644 --- a/src/silx/gui/plot/_utils/__init__.py +++ b/src/silx/gui/plot/_utils/__init__.py @@ -1,7 +1,6 @@ -# coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2021 European Synchrotron Radiation Facility +# Copyright (c) 2004-2023 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 @@ -32,11 +31,12 @@ __date__ = "21/03/2017" import numpy from .panzoom import FLOAT32_SAFE_MIN, FLOAT32_MINPOS, FLOAT32_SAFE_MAX -from .panzoom import applyZoomToPlot, applyPan, checkAxisLimits +from .panzoom import applyZoomToPlot, applyPan, checkAxisLimits, EnabledAxes -def addMarginsToLimits(margins, isXLog, isYLog, - xMin, xMax, yMin, yMax, y2Min=None, y2Max=None): +def addMarginsToLimits( + margins, isXLog, isYLog, xMin, xMax, yMin, yMax, y2Min=None, y2Max=None +): """Returns updated limits by extending them with margins. :param margins: The ratio of the margins to add or None for no margins. @@ -56,35 +56,35 @@ def addMarginsToLimits(margins, isXLog, isYLog, xMin -= xMinMargin * xRange xMax += xMaxMargin * xRange - elif xMin > 0. and xMax > 0.: # Log scale + elif xMin > 0.0 and xMax > 0.0: # Log scale # Do not apply margins if limits < 0 xMinLog, xMaxLog = numpy.log10(xMin), numpy.log10(xMax) xRangeLog = xMaxLog - xMinLog - xMin = pow(10., xMinLog - xMinMargin * xRangeLog) - xMax = pow(10., xMaxLog + xMaxMargin * xRangeLog) + xMin = pow(10.0, xMinLog - xMinMargin * xRangeLog) + xMax = pow(10.0, xMaxLog + xMaxMargin * xRangeLog) if not isYLog: yRange = yMax - yMin yMin -= yMinMargin * yRange yMax += yMaxMargin * yRange - elif yMin > 0. and yMax > 0.: # Log scale + elif yMin > 0.0 and yMax > 0.0: # Log scale # Do not apply margins if limits < 0 yMinLog, yMaxLog = numpy.log10(yMin), numpy.log10(yMax) yRangeLog = yMaxLog - yMinLog - yMin = pow(10., yMinLog - yMinMargin * yRangeLog) - yMax = pow(10., yMaxLog + yMaxMargin * yRangeLog) + yMin = pow(10.0, yMinLog - yMinMargin * yRangeLog) + yMax = pow(10.0, yMaxLog + yMaxMargin * yRangeLog) if y2Min is not None and y2Max is not None: if not isYLog: yRange = y2Max - y2Min y2Min -= yMinMargin * yRange y2Max += yMaxMargin * yRange - elif y2Min > 0. and y2Max > 0.: # Log scale + elif y2Min > 0.0 and y2Max > 0.0: # Log scale # Do not apply margins if limits < 0 yMinLog, yMaxLog = numpy.log10(y2Min), numpy.log10(y2Max) yRangeLog = yMaxLog - yMinLog - y2Min = pow(10., yMinLog - yMinMargin * yRangeLog) - y2Max = pow(10., yMaxLog + yMaxMargin * yRangeLog) + y2Min = pow(10.0, yMinLog - yMinMargin * yRangeLog) + y2Max = pow(10.0, yMaxLog + yMaxMargin * yRangeLog) if y2Min is None or y2Max is None: return xMin, xMax, yMin, yMax diff --git a/src/silx/gui/plot/_utils/delaunay.py b/src/silx/gui/plot/_utils/delaunay.py deleted file mode 100644 index 49ad05f..0000000 --- a/src/silx/gui/plot/_utils/delaunay.py +++ /dev/null @@ -1,62 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 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. -# -# ###########################################################################*/ -"""Wrapper over Delaunay implementation""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "02/05/2019" - - -import logging -import sys - -import numpy - - -_logger = logging.getLogger(__name__) - - -def delaunay(x, y): - """Returns Delaunay instance for x, y points - - :param numpy.ndarray x: - :param numpy.ndarray y: - :rtype: Union[None,scipy.spatial.Delaunay] - """ - # Lazy-loading of Delaunay - try: - from scipy.spatial import Delaunay as _Delaunay - except ImportError: # Fallback using local Delaunay - from silx.third_party.scipy_spatial import Delaunay as _Delaunay - - points = numpy.array((x, y)).T - try: - delaunay = _Delaunay(points) - except (RuntimeError, ValueError): - _logger.error("Delaunay tesselation failed: %s", - sys.exc_info()[1]) - delaunay = None - - return delaunay diff --git a/src/silx/gui/plot/_utils/dtime_ticklayout.py b/src/silx/gui/plot/_utils/dtime_ticklayout.py index ebf775b..ba0fda7 100644 --- a/src/silx/gui/plot/_utils/dtime_ticklayout.py +++ b/src/silx/gui/plot/_utils/dtime_ticklayout.py @@ -1,7 +1,6 @@ -# coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2018 European Synchrotron Radiation Facility +# Copyright (c) 2014-2023 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 @@ -22,15 +21,16 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""This module implements date-time labels layout on graph axes.""" +from __future__ import annotations -from __future__ import absolute_import, division, unicode_literals +"""This module implements date-time labels layout on graph axes.""" __authors__ = ["P. Kenter"] __license__ = "MIT" __date__ = "04/04/2018" +from collections.abc import Sequence import datetime as dt import enum import logging @@ -51,14 +51,15 @@ SECONDS_PER_MINUTE = 60 SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR SECONDS_PER_YEAR = 365.25 * SECONDS_PER_DAY -SECONDS_PER_MONTH_AVERAGE = SECONDS_PER_YEAR / 12 # Seconds per average month +SECONDS_PER_MONTH_AVERAGE = SECONDS_PER_YEAR / 12 # Seconds per average month # No dt.timezone in Python 2.7 so we use dateutil.tz.tzutc _EPOCH = dt.datetime(1970, 1, 1, tzinfo=dateutil.tz.tzutc()) + def timestamp(dtObj): - """ Returns POSIX timestamp of a datetime objects. + """Returns POSIX timestamp of a datetime objects. If the dtObj object has a timestamp() method (python 3.3), this is used. Otherwise (e.g. python 2.7) it is calculated here. @@ -76,9 +77,22 @@ def timestamp(dtObj): else: # Back ported from Python 3.5 if dtObj.tzinfo is None: - return time.mktime((dtObj.year, dtObj.month, dtObj.day, - dtObj.hour, dtObj.minute, dtObj.second, - -1, -1, -1)) + dtObj.microsecond / 1e6 + return ( + time.mktime( + ( + dtObj.year, + dtObj.month, + dtObj.day, + dtObj.hour, + dtObj.minute, + dtObj.second, + -1, + -1, + -1, + ) + ) + + dtObj.microsecond / 1e6 + ) else: return (dtObj - _EPOCH).total_seconds() @@ -95,7 +109,7 @@ class DtUnit(enum.Enum): def getDateElement(dateTime, unit): - """ Picks the date element with the unit from the dateTime + """Picks the date element with the unit from the dateTime E.g. getDateElement(datetime(1970, 5, 6), DtUnit.Day) will return 6 @@ -121,7 +135,7 @@ def getDateElement(dateTime, unit): def setDateElement(dateTime, value, unit): - """ Returns a copy of dateTime with the tickStep unit set to value + """Returns a copy of dateTime with the tickStep unit set to value :param datetime.datetime: date time object :param int value: value to set @@ -129,8 +143,9 @@ def setDateElement(dateTime, value, unit): :return: datetime.datetime """ intValue = int(value) - _logger.debug("setDateElement({}, {} (int={}), {})" - .format(dateTime, value, intValue, unit)) + _logger.debug( + "setDateElement({}, {} (int={}), {})".format(dateTime, value, intValue, unit) + ) year = dateTime.year month = dateTime.month @@ -157,16 +172,19 @@ def setDateElement(dateTime, value, unit): else: raise ValueError("Unexpected DtUnit: {}".format(unit)) - _logger.debug("creating date time {}" - .format((year, month, day, hour, minute, second, microsecond))) - - return dt.datetime(year, month, day, hour, minute, second, microsecond, - tzinfo=dateTime.tzinfo) + _logger.debug( + "creating date time {}".format( + (year, month, day, hour, minute, second, microsecond) + ) + ) + return dt.datetime( + year, month, day, hour, minute, second, microsecond, tzinfo=dateTime.tzinfo + ) def roundToElement(dateTime, unit): - """ Returns a copy of dateTime rounded to given unit + """Returns a copy of dateTime rounded to given unit :param datetime.datetime: date time object :param DtUnit unit: unit @@ -181,7 +199,7 @@ def roundToElement(dateTime, unit): microsecond = dateTime.microsecond if unit.value < DtUnit.YEARS.value: - pass # Never round years + pass # Never round years if unit.value < DtUnit.MONTHS.value: month = 1 if unit.value < DtUnit.DAYS.value: @@ -195,14 +213,15 @@ def roundToElement(dateTime, unit): if unit.value < DtUnit.MICRO_SECONDS.value: microsecond = 0 - result = dt.datetime(year, month, day, hour, minute, second, microsecond, - tzinfo=dateTime.tzinfo) + result = dt.datetime( + year, month, day, hour, minute, second, microsecond, tzinfo=dateTime.tzinfo + ) return result def addValueToDate(dateTime, value, unit): - """ Adds a value with unit to a dateTime. + """Adds a value with unit to a dateTime. Uses dateutil.relativedelta.relativedelta from the standard library to do the actual math. This function doesn't allow for fractional month or years, @@ -212,14 +231,15 @@ def addValueToDate(dateTime, value, unit): :param float value: value to be added :param DtUnit unit: of the value :return: + :raises ValueError: unit is unsupported or result is out of datetime bounds """ - #logger.debug("addValueToDate({}, {}, {})".format(dateTime, value, unit)) + # logger.debug("addValueToDate({}, {}, {})".format(dateTime, value, unit)) if unit == DtUnit.YEARS: - intValue = int(value) # floats not implemented in relativeDelta(years) + intValue = int(value) # floats not implemented in relativeDelta(years) return dateTime + relativedelta(years=intValue) elif unit == DtUnit.MONTHS: - intValue = int(value) # floats not implemented in relativeDelta(mohths) + intValue = int(value) # floats not implemented in relativeDelta(mohths) return dateTime + relativedelta(months=intValue) elif unit == DtUnit.DAYS: return dateTime + relativedelta(days=value) @@ -236,7 +256,7 @@ def addValueToDate(dateTime, value, unit): def bestUnit(durationInSeconds): - """ Gets the best tick spacing given a duration in seconds. + """Gets the best tick spacing given a duration in seconds. :param durationInSeconds: time span duration in seconds :return: DtUnit enumeration. @@ -266,8 +286,7 @@ def bestUnit(durationInSeconds): elif durationInSeconds > 1 * 2: return (durationInSeconds, DtUnit.SECONDS) else: - return (durationInSeconds * MICROSECONDS_PER_SECOND, - DtUnit.MICRO_SECONDS) + return (durationInSeconds * MICROSECONDS_PER_SECOND, DtUnit.MICRO_SECONDS) NICE_DATE_VALUES = { @@ -277,12 +296,12 @@ NICE_DATE_VALUES = { DtUnit.HOURS: [1, 2, 3, 4, 6, 12], DtUnit.MINUTES: [1, 2, 3, 5, 10, 15, 30], DtUnit.SECONDS: [1, 2, 3, 5, 10, 15, 30], - DtUnit.MICRO_SECONDS : [1.0, 2.0, 5.0, 10.0], # floats for microsec + DtUnit.MICRO_SECONDS: [1.0, 2.0, 3.0, 4.0, 5.0, 10.0], # floats for microsec } def bestFormatString(spacing, unit): - """ Finds the best format string given the spacing and DtUnit. + """Finds the best format string given the spacing and DtUnit. If the spacing is a fractional number < 1 the format string will take this into account @@ -312,8 +331,31 @@ def bestFormatString(spacing, unit): raise ValueError("Unexpected DtUnit: {}".format(unit)) +def formatDatetimes( + datetimes: Sequence[dt.datetime], spacing: int | None, unit: DtUnit | None +) -> dict[dt.datetime, str]: + """Returns formatted string for each datetime according to tick spacing and time unit""" + if spacing is None or unit is None: + # Locator has no spacing or units yet: Use elaborate fmtString + return { + datetime: datetime.strftime("Y-%m-%d %H:%M:%S") for datetime in datetimes + } + + formatString = bestFormatString(spacing, unit) + if unit != DtUnit.MICRO_SECONDS: + return {datetime: datetime.strftime(formatString) for datetime in datetimes} + + # For microseconds: Strip leading/trailing zeros + texts = tuple(datetime.strftime(formatString) for datetime in datetimes) + nzeros = min(len(text) - len(text.rstrip("0")) for text in texts) + return { + datetime: text[0 if text[0] != "0" else 1 : -min(nzeros, 5)] + for datetime, text in zip(datetimes, texts) + } + + def niceDateTimeElement(value, unit, isRound=False): - """ Uses the Nice Numbers algorithm to determine a nice value. + """Uses the Nice Numbers algorithm to determine a nice value. The fractions are optimized for the unit of the date element. """ @@ -328,10 +370,8 @@ def niceDateTimeElement(value, unit, isRound=False): def findStartDate(dMin, dMax, nTicks): - """ Rounds a date down to the nearest nice number of ticks - """ - assert dMax >= dMin, \ - "dMin ({}) should come before dMax ({})".format(dMin, dMax) + """Rounds a date down to the nearest nice number of ticks""" + assert dMax >= dMin, "dMin ({}) should come before dMax ({})".format(dMin, dMax) if dMin == dMax: # Fallback when range is smaller than microsecond resolution @@ -339,31 +379,42 @@ def findStartDate(dMin, dMax, nTicks): delta = dMax - dMin lengthSec = delta.total_seconds() - _logger.debug("findStartDate: {}, {} (duration = {} sec, {} days)" - .format(dMin, dMax, lengthSec, lengthSec / SECONDS_PER_DAY)) + _logger.debug( + "findStartDate: {}, {} (duration = {} sec, {} days)".format( + dMin, dMax, lengthSec, lengthSec / SECONDS_PER_DAY + ) + ) length, unit = bestUnit(lengthSec) niceLength = niceDateTimeElement(length, unit) - _logger.debug("Length: {:8.3f} {} (nice = {})" - .format(length, unit.name, niceLength)) + _logger.debug( + "Length: {:8.3f} {} (nice = {})".format(length, unit.name, niceLength) + ) niceSpacing = niceDateTimeElement(niceLength / nTicks, unit, isRound=True) - _logger.debug("Spacing: {:8.3f} {} (nice = {})" - .format(niceLength / nTicks, unit.name, niceSpacing)) + _logger.debug( + "Spacing: {:8.3f} {} (nice = {})".format( + niceLength / nTicks, unit.name, niceSpacing + ) + ) dVal = getDateElement(dMin, unit) - if unit == DtUnit.MONTHS: # TODO: better rounding? - niceVal = math.floor((dVal-1) / niceSpacing) * niceSpacing + 1 + if unit == DtUnit.MONTHS: # TODO: better rounding? + niceVal = math.floor((dVal - 1) / niceSpacing) * niceSpacing + 1 elif unit == DtUnit.DAYS: - niceVal = math.floor((dVal-1) / niceSpacing) * niceSpacing + 1 + niceVal = math.floor((dVal - 1) / niceSpacing) * niceSpacing + 1 else: niceVal = math.floor(dVal / niceSpacing) * niceSpacing - _logger.debug("StartValue: dVal = {}, niceVal: {} ({})" - .format(dVal, niceVal, unit.name)) + if unit == DtUnit.YEARS and niceVal <= dt.MINYEAR: + niceVal = max(1, niceSpacing) + + _logger.debug( + "StartValue: dVal = {}, niceVal: {} ({})".format(dVal, niceVal, unit.name) + ) startDate = roundToElement(dMin, unit) startDate = setDateElement(startDate, niceVal, unit) @@ -371,8 +422,8 @@ def findStartDate(dMin, dMax, nTicks): return startDate, niceSpacing, unit -def dateRange(dMin, dMax, step, unit, includeFirstBeyond = False): - """ Generates a range of dates +def dateRange(dMin, dMax, step, unit, includeFirstBeyond=False): + """Generates a range of dates :param datetime dMin: start date :param datetime dMax: end date @@ -383,8 +434,7 @@ def dateRange(dMin, dMax, step, unit, includeFirstBeyond = False): datetime will always be smaller than dMax. :return: """ - if (unit == DtUnit.YEARS or unit == DtUnit.MONTHS or - unit == DtUnit.MICRO_SECONDS): + if unit == DtUnit.YEARS or unit == DtUnit.MONTHS or unit == DtUnit.MICRO_SECONDS: # No support for fractional month or year and resolution is microsecond # In those cases, make sure the step is at least 1 step = max(1, step) @@ -394,13 +444,15 @@ def dateRange(dMin, dMax, step, unit, includeFirstBeyond = False): dateTime = dMin while dateTime < dMax: yield dateTime - dateTime = addValueToDate(dateTime, step, unit) + try: + dateTime = addValueToDate(dateTime, step, unit) + except ValueError: + return # current dateTime is out of datetime bounds if includeFirstBeyond: yield dateTime - def calcTicks(dMin, dMax, nTicks): """Returns tick positions. @@ -410,33 +462,19 @@ def calcTicks(dMin, dMax, nTicks): ticks may differ. :returns: (list of datetimes, DtUnit) tuple """ - _logger.debug("Calc calcTicks({}, {}, nTicks={})" - .format(dMin, dMax, nTicks)) + _logger.debug("Calc calcTicks({}, {}, nTicks={})".format(dMin, dMax, nTicks)) startDate, niceSpacing, unit = findStartDate(dMin, dMax, nTicks) result = [] - for d in dateRange(startDate, dMax, niceSpacing, unit, - includeFirstBeyond=True): + for d in dateRange(startDate, dMax, niceSpacing, unit, includeFirstBeyond=True): result.append(d) - assert result[0] <= dMin, \ - "First nice date ({}) should be <= dMin {}".format(result[0], dMin) - - assert result[-1] >= dMax, \ - "Last nice date ({}) should be >= dMax {}".format(result[-1], dMax) - return result, niceSpacing, unit def calcTicksAdaptive(dMin, dMax, axisLength, tickDensity): - """ Calls calcTicks with a variable number of ticks, depending on axisLength - """ + """Calls calcTicks with a variable number of ticks, depending on axisLength""" # At least 2 ticks nticks = max(2, int(round(tickDensity * axisLength))) - return calcTicks(dMin, dMax, nticks) - - - - - + return calcTicks(dMin, dMax, nticks) diff --git a/src/silx/gui/plot/_utils/panzoom.py b/src/silx/gui/plot/_utils/panzoom.py index 77efd10..cac591d 100644 --- a/src/silx/gui/plot/_utils/panzoom.py +++ b/src/silx/gui/plot/_utils/panzoom.py @@ -1,7 +1,6 @@ -# coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2021 European Synchrotron Radiation Facility +# Copyright (c) 2004-2023 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 @@ -24,6 +23,8 @@ # ###########################################################################*/ """Functions to apply pan and zoom on a Plot""" +from __future__ import annotations + __authors__ = ["T. Vincent", "V. Valls"] __license__ = "MIT" __date__ = "08/08/2017" @@ -31,6 +32,7 @@ __date__ = "08/08/2017" import logging import math +from typing import NamedTuple import numpy @@ -47,11 +49,11 @@ FLOAT32_SAFE_MAX = 1e37 # TODO double support -def checkAxisLimits(vmin, vmax, isLog: bool=False, name: str=""): +def checkAxisLimits(vmin: float, vmax: float, 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 + :param vmin: Min axis value + :param vmax: Max axis value :return: (min, max) making sure min < max :rtype: 2-tuple of float """ @@ -60,11 +62,11 @@ def checkAxisLimits(vmin, vmax, isLog: bool=False, name: str=""): vmin = numpy.clip(vmin, min_, FLOAT32_SAFE_MAX) if vmax < vmin: - _logger.debug('%s axis: max < min, inverting limits.', name) + _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.: + _logger.debug("%s axis: max == min, expanding limits.", name) + if vmin == 0.0: vmin, vmax = -0.1, 0.1 elif vmin < 0: vmax *= 0.9 @@ -76,26 +78,27 @@ def checkAxisLimits(vmin, vmax, isLog: bool=False, name: str=""): return vmin, vmax -def scale1DRange(min_, max_, center, scale, isLog): +def scale1DRange( + min_: float, max_: float, center: float, scale: float, isLog: bool +) -> tuple[float, float]: """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) + :param min_: The current min value of the range. + :param max_: The current max value of the range. + :param center: The center of the zoom (i.e., invariant point). + :param scale: The scale to use for zoom + :param isLog: Whether using log scale or not. + :return: The zoomed range (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 + min_ = numpy.log10(min_) if min_ > 0.0 else FLOAT32_MINPOS + center = numpy.log10(center) if center > 0.0 else FLOAT32_MINPOS + max_ = numpy.log10(max_) if max_ > 0.0 else FLOAT32_MINPOS if min_ == max_: return min_, max_ @@ -103,12 +106,12 @@ def scale1DRange(min_, max_, center, scale, isLog): offset = (center - min_) / (max_ - min_) range_ = (max_ - min_) / scale newMin = center - offset * range_ - newMax = center + (1. - offset) * range_ + newMax = center + (1.0 - offset) * range_ if isLog: # No overflow as exponent is log10 of a float32 - newMin = pow(10., newMin) - newMax = pow(10., newMax) + newMin = pow(10.0, newMin) + newMax = pow(10.0, newMax) newMin = numpy.clip(newMin, FLOAT32_MINPOS, FLOAT32_SAFE_MAX) newMax = numpy.clip(newMax, FLOAT32_MINPOS, FLOAT32_SAFE_MAX) else: @@ -117,16 +120,34 @@ def scale1DRange(min_, max_, center, scale, isLog): return newMin, newMax -def applyZoomToPlot(plot, scaleF, center=None): +class EnabledAxes(NamedTuple): + """Toggle zoom for each axis""" + + xaxis: bool = True + yaxis: bool = True + y2axis: bool = True + + def isDisabled(self) -> bool: + """True only if all axes are disabled""" + return not (self.xaxis or self.yaxis or self.y2axis) + + +def applyZoomToPlot( + plot, + scale: float, + center: tuple[float, float] = None, + enabled: EnabledAxes = EnabledAxes(), +): """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 scale: Scale factor of zoom. :param center: (x, y) coords in pixel coordinates of the zoom center. - :type center: 2-tuple of float + :param enabled: Toggle zoom for each axis independently """ xMin, xMax = plot.getXAxis().getLimits() yMin, yMax = plot.getYAxis().getLimits() + y2Min, y2Max = plot.getYAxis(axis="right").getLimits() if center is None: left, top, width, height = plot.getPlotBoundsInPixels() @@ -137,18 +158,23 @@ def applyZoomToPlot(plot, scaleF, center=None): dataCenterPos = plot.pixelToData(cx, cy) assert dataCenterPos is not None - xMin, xMax = scale1DRange(xMin, xMax, dataCenterPos[0], scaleF, - plot.getXAxis()._isLogarithmic()) + if enabled.xaxis: + xMin, xMax = scale1DRange( + xMin, xMax, dataCenterPos[0], scale, plot.getXAxis()._isLogarithmic() + ) - yMin, yMax = scale1DRange(yMin, yMax, dataCenterPos[1], scaleF, - plot.getYAxis()._isLogarithmic()) + if enabled.yaxis: + yMin, yMax = scale1DRange( + yMin, yMax, dataCenterPos[1], scale, 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()) + if enabled.y2axis: + dataPos = plot.pixelToData(cx, cy, axis="right") + assert dataPos is not None + y2Center = dataPos[1] + y2Min, y2Max = scale1DRange( + y2Min, y2Max, y2Center, scale, plot.getYAxis()._isLogarithmic() + ) plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max) @@ -167,15 +193,15 @@ def applyPan(min_, max_, panFactor, isLog10): :return: New min and max value with pan applied. :rtype: 2-tuple of float. """ - if isLog10 and min_ > 0.: + if isLog10 and min_ > 0.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) + newMin = pow(10.0, logMin + logOffset) + newMax = pow(10.0, logMax + logOffset) # Takes care of out-of-range values - if newMin > 0. and newMax < float('inf'): + if newMin > 0.0 and newMax < float("inf"): min_, max_ = newMin, newMax else: @@ -183,13 +209,14 @@ def applyPan(min_, max_, panFactor, isLog10): newMin, newMax = min_ + offset, max_ + offset # Takes care of out-of-range values - if newMin > - float('inf') and newMax < float('inf'): + 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 @@ -204,10 +231,17 @@ class ViewConstraints(object): 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): + 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 @@ -239,7 +273,6 @@ class ViewConstraints(object): 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 @@ -263,7 +296,11 @@ class ViewConstraints(object): # 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: + 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) @@ -299,8 +336,12 @@ class ViewConstraints(object): 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] + 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: diff --git a/src/silx/gui/plot/_utils/setup.py b/src/silx/gui/plot/_utils/setup.py deleted file mode 100644 index 0271745..0000000 --- a/src/silx/gui/plot/_utils/setup.py +++ /dev/null @@ -1,42 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "21/03/2017" - - -from numpy.distutils.misc_util import Configuration - - -def configuration(parent_package='', top_path=None): - config = Configuration('_utils', parent_package, top_path) - config.add_subpackage('test') - return config - - -if __name__ == "__main__": - from numpy.distutils.core import setup - - setup(configuration=configuration) diff --git a/src/silx/gui/plot/_utils/test/__init__.py b/src/silx/gui/plot/_utils/test/__init__.py index 3ad225d..78821ec 100644 --- a/src/silx/gui/plot/_utils/test/__init__.py +++ b/src/silx/gui/plot/_utils/test/__init__.py @@ -1,4 +1,3 @@ -# coding: utf-8 # /*########################################################################## # # Copyright (c) 2016-2018 European Synchrotron Radiation Facility diff --git a/src/silx/gui/plot/_utils/test/test_dtime_ticklayout.py b/src/silx/gui/plot/_utils/test/test_dtime_ticklayout.py index 8d35acf..adcb9c9 100644 --- a/src/silx/gui/plot/_utils/test/test_dtime_ticklayout.py +++ b/src/silx/gui/plot/_utils/test/test_dtime_ticklayout.py @@ -1,7 +1,6 @@ -# coding: utf-8 # /*########################################################################## # -# Copyright (c) 2015-2018 European Synchrotron Radiation Facility +# Copyright (c) 2015-2022 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 @@ -23,57 +22,66 @@ # # ###########################################################################*/ -from __future__ import absolute_import, division, unicode_literals - __authors__ = ["P. Kenter"] __license__ = "MIT" __date__ = "06/04/2018" import datetime as dt -import unittest +import pytest + +from silx.gui.plot._utils.dtime_ticklayout import calcTicks, DtUnit, SECONDS_PER_YEAR -from silx.gui.plot._utils.dtime_ticklayout import ( - calcTicks, DtUnit, SECONDS_PER_YEAR) +def testSmallMonthlySpacing(): + """Tests a range that did result in a spacing of less than 1 month. + It is impossible to add fractional month so the unit must be in days + """ + from dateutil import parser -class TestTickLayout(unittest.TestCase): - """Test ticks layout algorithms""" + d1 = parser.parse("2017-01-03 13:15:06.000044") + d2 = parser.parse("2017-03-08 09:16:16.307584") + _ticks, _units, spacing = calcTicks(d1, d2, nTicks=4) - def testSmallMonthlySpacing(self): - """ Tests a range that did result in a spacing of less than 1 month. - It is impossible to add fractional month so the unit must be in days - """ - from dateutil import parser - d1 = parser.parse("2017-01-03 13:15:06.000044") - d2 = parser.parse("2017-03-08 09:16:16.307584") - _ticks, _units, spacing = calcTicks(d1, d2, nTicks=4) + assert spacing == DtUnit.DAYS - self.assertEqual(spacing, DtUnit.DAYS) +def testNoCrash(): + """Creates many combinations of and number-of-ticks and end-dates; + tests that it doesn't give an exception and returns a reasonable number + of ticks. + """ + d1 = dt.datetime(2017, 1, 3, 13, 15, 6, 44) - def testNoCrash(self): - """ Creates many combinations of and number-of-ticks and end-dates; - tests that it doesn't give an exception and returns a reasonable number - of ticks. - """ - d1 = dt.datetime(2017, 1, 3, 13, 15, 6, 44) + value = 100e-6 # Start at 100 micro sec range. - value = 100e-6 # Start at 100 micro sec range. + while value <= 200 * SECONDS_PER_YEAR: + d2 = d1 + dt.timedelta(microseconds=value * 1e6) # end date range - while value <= 200 * SECONDS_PER_YEAR: + for numTicks in range(2, 12): + ticks, _, _ = calcTicks(d1, d2, numTicks) - d2 = d1 + dt.timedelta(microseconds=value*1e6) # end date range + margin = 2.5 + assert ( + numTicks / margin <= len(ticks) <= numTicks * margin + ), "Condition {} <= {} <= {} failed for # ticks={} and d2={}:".format( + numTicks / margin, len(ticks), numTicks * margin, numTicks, d2 + ) - for numTicks in range(2, 12): - ticks, _, _ = calcTicks(d1, d2, numTicks) + value = value * 1.5 # let date period grow exponentially - margin = 2.5 - self.assertTrue( - numTicks/margin <= len(ticks) <= numTicks*margin, - "Condition {} <= {} <= {} failed for # ticks={} and d2={}:" - .format(numTicks/margin, len(ticks), numTicks * margin, - numTicks, d2)) - value = value * 1.5 # let date period grow exponentially +@pytest.mark.parametrize( + "dMin, dMax", + [ + (dt.datetime(1, 1, 1), dt.datetime(400, 1, 1)), + (dt.datetime(4000, 1, 1), dt.datetime(9999, 1, 1)), + (dt.datetime(1, 1, 1), dt.datetime(9999, 12, 23)), + ], +) +def testCalcTicksOutOfBoundTicks(dMin, dMax): + """Test tick generation with values leading to out-of-bound ticks""" + ticks, _, unit = calcTicks(dMin, dMax, nTicks=5) + assert len(ticks) != 0 + assert unit == DtUnit.YEARS diff --git a/src/silx/gui/plot/_utils/test/test_ticklayout.py b/src/silx/gui/plot/_utils/test/test_ticklayout.py index 884b71b..1413563 100644 --- a/src/silx/gui/plot/_utils/test/test_ticklayout.py +++ b/src/silx/gui/plot/_utils/test/test_ticklayout.py @@ -1,4 +1,3 @@ -# coding: utf-8 # /*########################################################################## # # Copyright (c) 2015-2017 European Synchrotron Radiation Facility @@ -23,14 +22,11 @@ # # ###########################################################################*/ -from __future__ import absolute_import, division, unicode_literals - __authors__ = ["T. Vincent"] __license__ = "MIT" __date__ = "17/01/2018" -import unittest import numpy from silx.utils.testutils import ParametricTestCase @@ -44,10 +40,10 @@ class TestTickLayout(ParametricTestCase): def testTicks(self): """Test of :func:`ticks`""" tests = { # (vmin, vmax): ref_ticks - (1., 1.): (1.,), + (1.0, 1.0): (1.0,), (0.5, 10.5): (2.0, 4.0, 6.0, 8.0, 10.0), - (0.001, 0.005): (0.001, 0.002, 0.003, 0.004, 0.005) - } + (0.001, 0.005): (0.001, 0.002, 0.003, 0.004, 0.005), + } for (vmin, vmax), ref_ticks in tests.items(): with self.subTest(vmin=vmin, vmax=vmax): @@ -58,9 +54,9 @@ class TestTickLayout(ParametricTestCase): """Minimalistic tests of :func:`niceNumbers`""" tests = { # (vmin, vmax): ref_ticks (0.5, 10.5): (0.0, 12.0, 2.0, 0), - (10000., 10000.5): (10000.0, 10000.5, 0.1, 1), - (0.001, 0.005): (0.001, 0.005, 0.001, 3) - } + (10000.0, 10000.5): (10000.0, 10000.5, 0.1, 1), + (0.001, 0.005): (0.001, 0.005, 0.001, 3), + } for (vmin, vmax), ref_ticks in tests.items(): with self.subTest(vmin=vmin, vmax=vmax): @@ -70,9 +66,9 @@ class TestTickLayout(ParametricTestCase): def testNiceNumbersLog(self): """Minimalistic tests of :func:`niceNumbersForLog10`""" tests = { # (log10(min), log10(max): ref_ticks - (0., 3.): (0, 3, 1, 0), - (-3., 3): (-3, 3, 1, 0), - (-32., 0.): (-36, 0, 6, 0) + (0.0, 3.0): (0, 3, 1, 0), + (-3.0, 3): (-3, 3, 1, 0), + (-32.0, 0.0): (-36, 0, 6, 0), } for (vmin, vmax), ref_ticks in tests.items(): diff --git a/src/silx/gui/plot/_utils/ticklayout.py b/src/silx/gui/plot/_utils/ticklayout.py index c9fd3e6..3678270 100644 --- a/src/silx/gui/plot/_utils/ticklayout.py +++ b/src/silx/gui/plot/_utils/ticklayout.py @@ -1,4 +1,3 @@ -# coding: utf-8 # /*########################################################################## # # Copyright (c) 2014-2018 European Synchrotron Radiation Facility @@ -24,8 +23,6 @@ # ###########################################################################*/ """This module implements labels layout on graph axes.""" -from __future__ import absolute_import, division, unicode_literals - __authors__ = ["T. Vincent"] __license__ = "MIT" __date__ = "18/10/2016" @@ -36,6 +33,7 @@ import math # utils ####################################################################### + def numberOfDigits(tickSpacing): """Returns the number of digits to display for text label. @@ -79,7 +77,7 @@ def numberOfDigits(tickSpacing): def niceNumGeneric(value, niceFractions=None, isRound=False): - """ A more generic implementation of the _niceNum function + """A more generic implementation of the _niceNum function Allows the user to specify the fractions instead of using a hardcoded list of [1, 2, 5, 10.0]. @@ -88,15 +86,15 @@ def niceNumGeneric(value, niceFractions=None, isRound=False): return value if niceFractions is None: # Use default values - niceFractions = 1., 2., 5., 10. - roundFractions = (1.5, 3., 7., 10.) if isRound else niceFractions + niceFractions = 1.0, 2.0, 5.0, 10.0 + roundFractions = (1.5, 3.0, 7.0, 10.0) if isRound else niceFractions else: roundFractions = list(niceFractions) if isRound: # Take the average with the next element. The last remains the same. for i in range(len(roundFractions) - 1): - roundFractions[i] = (niceFractions[i] + niceFractions[i+1]) / 2 + roundFractions[i] = (niceFractions[i] + niceFractions[i + 1]) / 2 highest = niceFractions[-1] value = float(value) @@ -136,7 +134,7 @@ def niceNumbers(vMin, vMax, nTicks=5): def _frange(start, stop, step): """range for float (including stop).""" - assert step >= 0. + assert step >= 0.0 while start <= stop: yield start start += step @@ -169,7 +167,7 @@ def ticks(vMin, vMax, nbTicks=5): nfrac = numberOfDigits(vMax - vMin) # Generate labels - format_ = '%g' if nfrac == 0 else '%.{}f'.format(nfrac) + format_ = "%g" if nfrac == 0 else "%.{}f".format(nfrac) labels = [format_ % tick for tick in positions] return positions, labels @@ -197,6 +195,7 @@ def niceNumbersAdaptative(vMin, vMax, axisLength, tickDensity): # Nice Numbers for log scale ################################################## + def niceNumbersForLog10(minLog, maxLog, nTicks=5): """Return tick positions for logarithmic scale @@ -212,7 +211,7 @@ def niceNumbersForLog10(minLog, maxLog, nTicks=5): rangelog = graphmaxlog - graphminlog if rangelog <= nTicks: - spacing = 1. + spacing = 1.0 else: spacing = math.floor(rangelog / nTicks) |