summaryrefslogtreecommitdiff
path: root/src/silx/gui/plot/_utils
diff options
context:
space:
mode:
Diffstat (limited to 'src/silx/gui/plot/_utils')
-rw-r--r--src/silx/gui/plot/_utils/__init__.py28
-rw-r--r--src/silx/gui/plot/_utils/delaunay.py62
-rw-r--r--src/silx/gui/plot/_utils/dtime_ticklayout.py180
-rw-r--r--src/silx/gui/plot/_utils/panzoom.py137
-rw-r--r--src/silx/gui/plot/_utils/setup.py42
-rw-r--r--src/silx/gui/plot/_utils/test/__init__.py1
-rw-r--r--src/silx/gui/plot/_utils/test/test_dtime_ticklayout.py80
-rw-r--r--src/silx/gui/plot/_utils/test/test_ticklayout.py22
-rw-r--r--src/silx/gui/plot/_utils/ticklayout.py19
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)