From 4e774db12d5ebe7a20eded6dd434a289e27999e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Picca=20Fr=C3=A9d=C3=A9ric-Emmanuel?= Date: Wed, 2 Feb 2022 14:19:58 +0100 Subject: New upstream version 1.0.0+dfsg --- src/silx/gui/plot/_utils/__init__.py | 92 +++++ src/silx/gui/plot/_utils/delaunay.py | 62 +++ src/silx/gui/plot/_utils/dtime_ticklayout.py | 442 +++++++++++++++++++++ src/silx/gui/plot/_utils/panzoom.py | 325 +++++++++++++++ src/silx/gui/plot/_utils/setup.py | 42 ++ src/silx/gui/plot/_utils/test/__init__.py | 24 ++ .../gui/plot/_utils/test/test_dtime_ticklayout.py | 79 ++++ src/silx/gui/plot/_utils/test/test_ticklayout.py | 81 ++++ src/silx/gui/plot/_utils/ticklayout.py | 267 +++++++++++++ 9 files changed, 1414 insertions(+) create mode 100644 src/silx/gui/plot/_utils/__init__.py create mode 100644 src/silx/gui/plot/_utils/delaunay.py create mode 100644 src/silx/gui/plot/_utils/dtime_ticklayout.py create mode 100644 src/silx/gui/plot/_utils/panzoom.py create mode 100644 src/silx/gui/plot/_utils/setup.py create mode 100644 src/silx/gui/plot/_utils/test/__init__.py create mode 100644 src/silx/gui/plot/_utils/test/test_dtime_ticklayout.py create mode 100644 src/silx/gui/plot/_utils/test/test_ticklayout.py create mode 100644 src/silx/gui/plot/_utils/ticklayout.py (limited to 'src/silx/gui/plot/_utils') diff --git a/src/silx/gui/plot/_utils/__init__.py b/src/silx/gui/plot/_utils/__init__.py new file mode 100644 index 0000000..ed87b18 --- /dev/null +++ b/src/silx/gui/plot/_utils/__init__.py @@ -0,0 +1,92 @@ +# 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. +# +# ###########################################################################*/ +"""Miscellaneous utility functions for the Plot""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "21/03/2017" + + +import numpy + +from .panzoom import FLOAT32_SAFE_MIN, FLOAT32_MINPOS, FLOAT32_SAFE_MAX +from .panzoom import applyZoomToPlot, applyPan, checkAxisLimits + + +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. + :type margins: A 4-tuple of floats as + (xMinMargin, xMaxMargin, yMinMargin, yMaxMargin) + + :return: The updated limits + :rtype: tuple of 4 or 6 floats: Either (xMin, xMax, yMin, yMax) or + (xMin, xMax, yMin, yMax, y2Min, y2Max) if y2Min and y2Max + are provided. + """ + if margins is not None: + xMinMargin, xMaxMargin, yMinMargin, yMaxMargin = margins + + if not isXLog: + xRange = xMax - xMin + xMin -= xMinMargin * xRange + xMax += xMaxMargin * xRange + + elif xMin > 0. and xMax > 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) + + if not isYLog: + yRange = yMax - yMin + yMin -= yMinMargin * yRange + yMax += yMaxMargin * yRange + elif yMin > 0. and yMax > 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) + + 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 + # 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) + + if y2Min is None or y2Max is None: + return xMin, xMax, yMin, yMax + else: + return xMin, xMax, yMin, yMax, y2Min, y2Max diff --git a/src/silx/gui/plot/_utils/delaunay.py b/src/silx/gui/plot/_utils/delaunay.py new file mode 100644 index 0000000..49ad05f --- /dev/null +++ b/src/silx/gui/plot/_utils/delaunay.py @@ -0,0 +1,62 @@ +# 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 new file mode 100644 index 0000000..ebf775b --- /dev/null +++ b/src/silx/gui/plot/_utils/dtime_ticklayout.py @@ -0,0 +1,442 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2018 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. +# +# ###########################################################################*/ +"""This module implements date-time labels layout on graph axes.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["P. Kenter"] +__license__ = "MIT" +__date__ = "04/04/2018" + + +import datetime as dt +import enum +import logging +import math +import time + +import dateutil.tz + +from dateutil.relativedelta import relativedelta + +from .ticklayout import niceNumGeneric + +_logger = logging.getLogger(__name__) + + +MICROSECONDS_PER_SECOND = 1000000 +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 + + +# 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. + + If the dtObj object has a timestamp() method (python 3.3), this is + used. Otherwise (e.g. python 2.7) it is calculated here. + + The POSIX timestamp is a floating point value of the number of seconds + since the start of an epoch (typically 1970-01-01). For details see: + https://docs.python.org/3/library/datetime.html#datetime.datetime.timestamp + + :param datetime.datetime dtObj: date-time representation. + :return: POSIX timestamp + :rtype: float + """ + if hasattr(dtObj, "timestamp"): + return dtObj.timestamp() + 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 + else: + return (dtObj - _EPOCH).total_seconds() + + +@enum.unique +class DtUnit(enum.Enum): + YEARS = 0 + MONTHS = 1 + DAYS = 2 + HOURS = 3 + MINUTES = 4 + SECONDS = 5 + MICRO_SECONDS = 6 # a fraction of a second + + +def getDateElement(dateTime, unit): + """ Picks the date element with the unit from the dateTime + + E.g. getDateElement(datetime(1970, 5, 6), DtUnit.Day) will return 6 + + :param datetime dateTime: date/time to pick from + :param DtUnit unit: The unit describing the date element. + """ + if unit == DtUnit.YEARS: + return dateTime.year + elif unit == DtUnit.MONTHS: + return dateTime.month + elif unit == DtUnit.DAYS: + return dateTime.day + elif unit == DtUnit.HOURS: + return dateTime.hour + elif unit == DtUnit.MINUTES: + return dateTime.minute + elif unit == DtUnit.SECONDS: + return dateTime.second + elif unit == DtUnit.MICRO_SECONDS: + return dateTime.microsecond + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + +def setDateElement(dateTime, value, unit): + """ Returns a copy of dateTime with the tickStep unit set to value + + :param datetime.datetime: date time object + :param int value: value to set + :param DtUnit unit: unit + :return: datetime.datetime + """ + intValue = int(value) + _logger.debug("setDateElement({}, {} (int={}), {})" + .format(dateTime, value, intValue, unit)) + + year = dateTime.year + month = dateTime.month + day = dateTime.day + hour = dateTime.hour + minute = dateTime.minute + second = dateTime.second + microsecond = dateTime.microsecond + + if unit == DtUnit.YEARS: + year = intValue + elif unit == DtUnit.MONTHS: + month = intValue + elif unit == DtUnit.DAYS: + day = intValue + elif unit == DtUnit.HOURS: + hour = intValue + elif unit == DtUnit.MINUTES: + minute = intValue + elif unit == DtUnit.SECONDS: + second = intValue + elif unit == DtUnit.MICRO_SECONDS: + microsecond = intValue + 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) + + + +def roundToElement(dateTime, unit): + """ Returns a copy of dateTime rounded to given unit + + :param datetime.datetime: date time object + :param DtUnit unit: unit + :return: datetime.datetime + """ + year = dateTime.year + month = dateTime.month + day = dateTime.day + hour = dateTime.hour + minute = dateTime.minute + second = dateTime.second + microsecond = dateTime.microsecond + + if unit.value < DtUnit.YEARS.value: + pass # Never round years + if unit.value < DtUnit.MONTHS.value: + month = 1 + if unit.value < DtUnit.DAYS.value: + day = 1 + if unit.value < DtUnit.HOURS.value: + hour = 0 + if unit.value < DtUnit.MINUTES.value: + minute = 0 + if unit.value < DtUnit.SECONDS.value: + second = 0 + if unit.value < DtUnit.MICRO_SECONDS.value: + microsecond = 0 + + 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. + + Uses dateutil.relativedelta.relativedelta from the standard library to do + the actual math. This function doesn't allow for fractional month or years, + so month and year are truncated to integers before adding. + + :param datetime dateTime: date time + :param float value: value to be added + :param DtUnit unit: of the value + :return: + """ + #logger.debug("addValueToDate({}, {}, {})".format(dateTime, value, unit)) + + if unit == DtUnit.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) + return dateTime + relativedelta(months=intValue) + elif unit == DtUnit.DAYS: + return dateTime + relativedelta(days=value) + elif unit == DtUnit.HOURS: + return dateTime + relativedelta(hours=value) + elif unit == DtUnit.MINUTES: + return dateTime + relativedelta(minutes=value) + elif unit == DtUnit.SECONDS: + return dateTime + relativedelta(seconds=value) + elif unit == DtUnit.MICRO_SECONDS: + return dateTime + relativedelta(microseconds=value) + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + +def bestUnit(durationInSeconds): + """ Gets the best tick spacing given a duration in seconds. + + :param durationInSeconds: time span duration in seconds + :return: DtUnit enumeration. + """ + + # Based on; https://stackoverflow.com/a/2144398/ + # If the duration is longer than two years the tick spacing will be in + # years. Else, if the duration is longer than two months, the spacing will + # be in months, Etcetera. + # + # This factor differs per unit. As a baseline it is 2, but for instance, + # for Months this needs to be higher (3>), This because it is impossible to + # have partial months so the tick spacing is always at least 1 month. A + # duration of two months would result in two ticks, which is too few. + # months would then results + + if durationInSeconds > SECONDS_PER_YEAR * 3: + return (durationInSeconds / SECONDS_PER_YEAR, DtUnit.YEARS) + elif durationInSeconds > SECONDS_PER_MONTH_AVERAGE * 3: + return (durationInSeconds / SECONDS_PER_MONTH_AVERAGE, DtUnit.MONTHS) + elif durationInSeconds > SECONDS_PER_DAY * 2: + return (durationInSeconds / SECONDS_PER_DAY, DtUnit.DAYS) + elif durationInSeconds > SECONDS_PER_HOUR * 2: + return (durationInSeconds / SECONDS_PER_HOUR, DtUnit.HOURS) + elif durationInSeconds > SECONDS_PER_MINUTE * 2: + return (durationInSeconds / SECONDS_PER_MINUTE, DtUnit.MINUTES) + elif durationInSeconds > 1 * 2: + return (durationInSeconds, DtUnit.SECONDS) + else: + return (durationInSeconds * MICROSECONDS_PER_SECOND, + DtUnit.MICRO_SECONDS) + + +NICE_DATE_VALUES = { + DtUnit.YEARS: [1, 2, 5, 10], + DtUnit.MONTHS: [1, 2, 3, 4, 6, 12], + DtUnit.DAYS: [1, 2, 3, 7, 14, 28], + 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 +} + + +def bestFormatString(spacing, unit): + """ 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 + + :param spacing: spacing between ticks + :param DtUnit unit: + :return: Format string for use in strftime + :rtype: str + """ + isSmall = spacing < 1 + + if unit == DtUnit.YEARS: + return "%Y-m" if isSmall else "%Y" + elif unit == DtUnit.MONTHS: + return "%Y-%m-%d" if isSmall else "%Y-%m" + elif unit == DtUnit.DAYS: + return "%H:%M" if isSmall else "%Y-%m-%d" + elif unit == DtUnit.HOURS: + return "%H:%M" if isSmall else "%H:%M" + elif unit == DtUnit.MINUTES: + return "%H:%M:%S" if isSmall else "%H:%M" + elif unit == DtUnit.SECONDS: + return "%S.%f" if isSmall else "%H:%M:%S" + elif unit == DtUnit.MICRO_SECONDS: + return "%S.%f" + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + +def niceDateTimeElement(value, unit, isRound=False): + """ Uses the Nice Numbers algorithm to determine a nice value. + + The fractions are optimized for the unit of the date element. + """ + + niceValues = NICE_DATE_VALUES[unit] + elemValue = niceNumGeneric(value, niceValues, isRound=isRound) + + if unit == DtUnit.YEARS or unit == DtUnit.MONTHS: + elemValue = max(1, int(elemValue)) + + return elemValue + + +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) + + if dMin == dMax: + # Fallback when range is smaller than microsecond resolution + return dMin, 1, DtUnit.MICRO_SECONDS + + delta = dMax - dMin + lengthSec = delta.total_seconds() + _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)) + + niceSpacing = niceDateTimeElement(niceLength / nTicks, unit, isRound=True) + + _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 + elif unit == DtUnit.DAYS: + 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)) + + startDate = roundToElement(dMin, unit) + startDate = setDateElement(startDate, niceVal, unit) + + return startDate, niceSpacing, unit + + +def dateRange(dMin, dMax, step, unit, includeFirstBeyond = False): + """ Generates a range of dates + + :param datetime dMin: start date + :param datetime dMax: end date + :param int step: the step size + :param DtUnit unit: the unit of the step size + :param bool includeFirstBeyond: if True the first date later than dMax will + be included in the range. If False (the default), the last generated + datetime will always be smaller than dMax. + :return: + """ + 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) + else: + assert step > 0, "tickstep is 0" + + dateTime = dMin + while dateTime < dMax: + yield dateTime + dateTime = addValueToDate(dateTime, step, unit) + + if includeFirstBeyond: + yield dateTime + + + +def calcTicks(dMin, dMax, nTicks): + """Returns tick positions. + + :param datetime.datetime dMin: The min value on the axis + :param datetime.datetime dMax: The max value on the axis + :param int nTicks: The target number of ticks. The actual number of found + ticks may differ. + :returns: (list of datetimes, DtUnit) tuple + """ + _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): + 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 + """ + # At least 2 ticks + nticks = max(2, int(round(tickDensity * axisLength))) + return calcTicks(dMin, dMax, nticks) + + + + + 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] diff --git a/src/silx/gui/plot/_utils/setup.py b/src/silx/gui/plot/_utils/setup.py new file mode 100644 index 0000000..0271745 --- /dev/null +++ b/src/silx/gui/plot/_utils/setup.py @@ -0,0 +1,42 @@ +# 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 new file mode 100644 index 0000000..3ad225d --- /dev/null +++ b/src/silx/gui/plot/_utils/test/__init__.py @@ -0,0 +1,24 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2018 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. +# +# ###########################################################################*/ diff --git a/src/silx/gui/plot/_utils/test/test_dtime_ticklayout.py b/src/silx/gui/plot/_utils/test/test_dtime_ticklayout.py new file mode 100644 index 0000000..8d35acf --- /dev/null +++ b/src/silx/gui/plot/_utils/test/test_dtime_ticklayout.py @@ -0,0 +1,79 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2018 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. +# +# ###########################################################################*/ + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["P. Kenter"] +__license__ = "MIT" +__date__ = "06/04/2018" + + +import datetime as dt +import unittest + + +from silx.gui.plot._utils.dtime_ticklayout import ( + calcTicks, DtUnit, SECONDS_PER_YEAR) + + +class TestTickLayout(unittest.TestCase): + """Test ticks layout algorithms""" + + 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) + + self.assertEqual(spacing, DtUnit.DAYS) + + + 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. + + while value <= 200 * SECONDS_PER_YEAR: + + d2 = d1 + dt.timedelta(microseconds=value*1e6) # end date range + + for numTicks in range(2, 12): + ticks, _, _ = calcTicks(d1, d2, numTicks) + + 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 diff --git a/src/silx/gui/plot/_utils/test/test_ticklayout.py b/src/silx/gui/plot/_utils/test/test_ticklayout.py new file mode 100644 index 0000000..884b71b --- /dev/null +++ b/src/silx/gui/plot/_utils/test/test_ticklayout.py @@ -0,0 +1,81 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-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. +# +# ###########################################################################*/ + +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 + +from silx.gui.plot._utils import ticklayout + + +class TestTickLayout(ParametricTestCase): + """Test ticks layout algorithms""" + + def testTicks(self): + """Test of :func:`ticks`""" + tests = { # (vmin, vmax): ref_ticks + (1., 1.): (1.,), + (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) + } + + for (vmin, vmax), ref_ticks in tests.items(): + with self.subTest(vmin=vmin, vmax=vmax): + ticks, labels = ticklayout.ticks(vmin, vmax) + self.assertTrue(numpy.allclose(ticks, ref_ticks)) + + def testNiceNumbers(self): + """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) + } + + for (vmin, vmax), ref_ticks in tests.items(): + with self.subTest(vmin=vmin, vmax=vmax): + ticks = ticklayout.niceNumbers(vmin, vmax) + self.assertEqual(ticks, ref_ticks) + + 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) + } + + for (vmin, vmax), ref_ticks in tests.items(): + with self.subTest(vmin=vmin, vmax=vmax): + ticks = ticklayout.niceNumbersForLog10(vmin, vmax) + self.assertEqual(ticks, ref_ticks) diff --git a/src/silx/gui/plot/_utils/ticklayout.py b/src/silx/gui/plot/_utils/ticklayout.py new file mode 100644 index 0000000..c9fd3e6 --- /dev/null +++ b/src/silx/gui/plot/_utils/ticklayout.py @@ -0,0 +1,267 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2018 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. +# +# ###########################################################################*/ +"""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" + + +import math + + +# utils ####################################################################### + +def numberOfDigits(tickSpacing): + """Returns the number of digits to display for text label. + + :param float tickSpacing: Step between ticks in data space. + :return: Number of digits to show for labels. + :rtype: int + """ + nfrac = int(-math.floor(math.log10(tickSpacing))) + if nfrac < 0: + nfrac = 0 + return nfrac + + +# Nice Numbers ################################################################ + +# This is the original niceNum implementation. For the date time ticks a more +# generic implementation was needed. +# +# def _niceNum(value, isRound=False): +# expvalue = math.floor(math.log10(value)) +# frac = value/pow(10., expvalue) +# if isRound: +# if frac < 1.5: +# nicefrac = 1. +# elif frac < 3.: # In niceNumGeneric this is (2+5)/2 = 3.5 +# nicefrac = 2. +# elif frac < 7.: +# nicefrac = 5. # In niceNumGeneric this is (5+10)/2 = 7.5 +# else: +# nicefrac = 10. +# else: +# if frac <= 1.: +# nicefrac = 1. +# elif frac <= 2.: +# nicefrac = 2. +# elif frac <= 5.: +# nicefrac = 5. +# else: +# nicefrac = 10. +# return nicefrac * pow(10., expvalue) + + +def niceNumGeneric(value, niceFractions=None, isRound=False): + """ 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]. + """ + if value == 0: + return value + + if niceFractions is None: # Use default values + niceFractions = 1., 2., 5., 10. + roundFractions = (1.5, 3., 7., 10.) 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 + + highest = niceFractions[-1] + value = float(value) + + expvalue = math.floor(math.log(value, highest)) + frac = value / pow(highest, expvalue) + + for niceFrac, roundFrac in zip(niceFractions, roundFractions): + if frac <= roundFrac: + return niceFrac * pow(highest, expvalue) + + # should not come here + assert False, "should not come here" + + +def niceNumbers(vMin, vMax, nTicks=5): + """Returns tick positions. + + This function implements graph labels layout using nice numbers + by Paul Heckbert from "Graphics Gems", Academic Press, 1990. + See `C code `_. + + :param float vMin: The min value on the axis + :param float vMax: The max value on the axis + :param int nTicks: The number of ticks to position + :returns: min, max, increment value of tick positions and + number of fractional digit to show + :rtype: tuple + """ + vrange = niceNumGeneric(vMax - vMin, isRound=False) + spacing = niceNumGeneric(vrange / nTicks, isRound=True) + graphmin = math.floor(vMin / spacing) * spacing + graphmax = math.ceil(vMax / spacing) * spacing + nfrac = numberOfDigits(spacing) + return graphmin, graphmax, spacing, nfrac + + +def _frange(start, stop, step): + """range for float (including stop).""" + assert step >= 0. + while start <= stop: + yield start + start += step + + +def ticks(vMin, vMax, nbTicks=5): + """Returns tick positions and labels using nice numbers algorithm. + + This enforces ticks to be within [vMin, vMax] range. + It returns at least 1 tick (when vMin == vMax). + + :param float vMin: The min value on the axis + :param float vMax: The max value on the axis + :param int nbTicks: The number of ticks to position + :returns: tick positions and corresponding text labels + :rtype: 2-tuple: list of float, list of string + """ + assert vMin <= vMax + if vMin == vMax: + positions = [vMin] + nfrac = 0 + + else: + start, end, step, nfrac = niceNumbers(vMin, vMax, nbTicks) + positions = [t for t in _frange(start, end, step) if vMin <= t <= vMax] + + # Makes sure there is at least 2 ticks + if len(positions) < 2: + positions = [vMin, vMax] + nfrac = numberOfDigits(vMax - vMin) + + # Generate labels + format_ = '%g' if nfrac == 0 else '%.{}f'.format(nfrac) + labels = [format_ % tick for tick in positions] + return positions, labels + + +def niceNumbersAdaptative(vMin, vMax, axisLength, tickDensity): + """Returns tick positions using :func:`niceNumbers` and a + density of ticks. + + axisLength and tickDensity are based on the same unit (e.g., pixel). + + :param float vMin: The min value on the axis + :param float vMax: The max value on the axis + :param float axisLength: The length of the axis. + :param float tickDensity: The density of ticks along the axis. + :returns: min, max, increment value of tick positions and + number of fractional digit to show + :rtype: tuple + """ + # At least 2 ticks + nticks = max(2, int(round(tickDensity * axisLength))) + tickmin, tickmax, step, nfrac = niceNumbers(vMin, vMax, nticks) + + return tickmin, tickmax, step, nfrac + + +# Nice Numbers for log scale ################################################## + +def niceNumbersForLog10(minLog, maxLog, nTicks=5): + """Return tick positions for logarithmic scale + + :param float minLog: log10 of the min value on the axis + :param float maxLog: log10 of the max value on the axis + :param int nTicks: The number of ticks to position + :returns: log10 of min, max, increment value of tick positions and + number of fractional digit to show + :rtype: tuple of int + """ + graphminlog = math.floor(minLog) + graphmaxlog = math.ceil(maxLog) + rangelog = graphmaxlog - graphminlog + + if rangelog <= nTicks: + spacing = 1. + else: + spacing = math.floor(rangelog / nTicks) + + graphminlog = math.floor(graphminlog / spacing) * spacing + graphmaxlog = math.ceil(graphmaxlog / spacing) * spacing + + nfrac = numberOfDigits(spacing) + + return int(graphminlog), int(graphmaxlog), int(spacing), nfrac + + +def niceNumbersAdaptativeForLog10(vMin, vMax, axisLength, tickDensity): + """Returns tick positions using :func:`niceNumbers` and a + density of ticks. + + axisLength and tickDensity are based on the same unit (e.g., pixel). + + :param float vMin: The min value on the axis + :param float vMax: The max value on the axis + :param float axisLength: The length of the axis. + :param float tickDensity: The density of ticks along the axis. + :returns: log10 of min, max, increment value of tick positions and + number of fractional digit to show + :rtype: tuple + """ + # At least 2 ticks + nticks = max(2, int(round(tickDensity * axisLength))) + tickmin, tickmax, step, nfrac = niceNumbersForLog10(vMin, vMax, nticks) + + return tickmin, tickmax, step, nfrac + + +def computeLogSubTicks(ticks, lowBound, highBound): + """Return the sub ticks for the log scale for all given ticks if subtick + is in [lowBound, highBound] + + :param ticks: log10 of the ticks + :param lowBound: the lower boundary of ticks + :param highBound: the higher boundary of ticks + :return: all the sub ticks contained in ticks (log10) + """ + if len(ticks) < 1: + return [] + + res = [] + for logPos in ticks: + dataOrigPos = logPos + for index in range(2, 10): + dataPos = dataOrigPos * index + if lowBound <= dataPos <= highBound: + res.append(dataPos) + return res -- cgit v1.2.3