From 3ed82886019f054d7b5d5b679b5e099eb99cb6ec Mon Sep 17 00:00:00 2001 From: Robert Jordens Date: Thu, 25 Feb 2016 20:34:04 +0100 Subject: [PATCH] scanwidget: add from current git --- artiq/gui/scanwidget.py | 545 ++++++++++++++++++++++++++++++++ artiq/gui/scientific_spinbox.py | 71 +++++ artiq/gui/ticker.py | 136 ++++++++ 3 files changed, 752 insertions(+) create mode 100644 artiq/gui/scanwidget.py create mode 100644 artiq/gui/scientific_spinbox.py create mode 100644 artiq/gui/ticker.py diff --git a/artiq/gui/scanwidget.py b/artiq/gui/scanwidget.py new file mode 100644 index 000000000..20facb2c0 --- /dev/null +++ b/artiq/gui/scanwidget.py @@ -0,0 +1,545 @@ +from PyQt5 import QtGui, QtCore, QtWidgets + +from .ticker import Ticker + + +class ScanAxis(QtWidgets.QWidget): + sigZoom = QtCore.pyqtSignal(float, int) + + def __init__(self, zoomFactor): + QtWidgets.QWidget.__init__(self) + self.proxy = None + self.sizePolicy().setControlType(QtWidgets.QSizePolicy.ButtonBox) + self.ticker = Ticker() + self.zoomFactor = zoomFactor + + def paintEvent(self, ev): + painter = QtGui.QPainter(self) + font = painter.font() + avgCharWidth = QtGui.QFontMetrics(font).averageCharWidth() + painter.setRenderHint(QtGui.QPainter.Antialiasing) + # The center of the slider handles should reflect what's displayed + # on the spinboxes. + painter.translate(self.proxy.slider.handleWidth()/2, self.height() - 5) + painter.drawLine(0, 0, self.width(), 0) + realMin = self.proxy.pixelToReal(0) + realMax = self.proxy.pixelToReal(self.width()) + + ticks, prefix, labels = self.ticker(realMin, realMax) + for t, l in zip(ticks, labels): + t = self.proxy.realToPixel(t) + textCenter = (len(l)/2.0)*avgCharWidth + painter.drawLine(t, 5, t, -5) + painter.drawText(t - textCenter, -10, l) + painter.resetTransform() + painter.drawText(0, 10, prefix) + # TODO: + # QtWidgets.QWidget.paintEvent(self, ev)? + # ev.accept() ? + + def wheelEvent(self, ev): + y = ev.angleDelta().y() + if y: + z = self.zoomFactor**(y / 120.) + # Remove the slider-handle shift correction, b/c none of the other + # widgets know about it. If we have the mouse directly over a tick + # during a zoom, it should appear as if we are doing zoom relative + # to the ticks which live in axis pixel-space, not slider + # pixel-space. + self.sigZoom.emit(z, ev.x() - self.proxy.slider.handleWidth()/2) + self.update() + ev.accept() + + +# Basic ideas from https://gist.github.com/Riateche/27e36977f7d5ea72cf4f +class ScanSlider(QtWidgets.QSlider): + sigMinMoved = QtCore.pyqtSignal(int) + sigMaxMoved = QtCore.pyqtSignal(int) + noSlider, minSlider, maxSlider = range(3) + maxStyle = "QSlider::handle {background:red}" + minStyle = "QSlider::handle {background:blue}" + + def __init__(self): + QtWidgets.QSlider.__init__(self, QtCore.Qt.Horizontal) + self.minPos = 0 # Pos and Val can differ in event handling. + # perhaps prevPos and currPos is more accurate. + self.maxPos = 99 + self.minVal = 0 # lower + self.maxVal = 99 # upper + self.offset = 0 + self.position = 0 + self.lastPressed = ScanSlider.noSlider + self.selectedHandle = ScanSlider.minSlider + self.upperPressed = QtWidgets.QStyle.SC_None + self.lowerPressed = QtWidgets.QStyle.SC_None + self.firstMovement = False # State var for handling slider overlap. + self.blockTracking = False + + # We need fake sliders to keep around so that we can dynamically + # set the stylesheets for drawing each slider later. See paintEvent. + self.dummyMinSlider = QtWidgets.QSlider() + self.dummyMaxSlider = QtWidgets.QSlider() + self.dummyMinSlider.setStyleSheet(ScanSlider.minStyle) + self.dummyMaxSlider.setStyleSheet(ScanSlider.maxStyle) + + # We basically superimpose two QSliders on top of each other, discarding + # the state that remains constant between the two when drawing. + # Everything except the handles remain constant. + def initHandleStyleOption(self, opt, handle): + self.initStyleOption(opt) + if handle == ScanSlider.minSlider: + opt.sliderPosition = self.minPos + opt.sliderValue = self.minVal + elif handle == ScanSlider.maxSlider: + opt.sliderPosition = self.maxPos + opt.sliderValue = self.maxVal + else: + pass # AssertionErrors + + # We get the range of each slider separately. + def pixelPosToRangeValue(self, pos): + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + + gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderGroove, + self) + sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderHandle, + self) + + sliderLength = sr.width() + sliderMin = gr.x() + # For historical reasons right() returns left()+width() - 1 + # x() is equivalent to left(). + sliderMax = gr.right() - sliderLength + 1 + return QtWidgets.QStyle.sliderValueFromPosition( + self.minimum(), self.maximum(), pos - sliderMin, + sliderMax - sliderMin, opt.upsideDown) + + def rangeValueToPixelPos(self, val): + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + + gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderGroove, + self) + sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderHandle, + self) + + sliderLength = sr.width() + sliderMin = gr.x() + sliderMax = gr.right() - sliderLength + 1 + + pixel = QtWidgets.QStyle.sliderPositionFromValue( + self.minimum(), self.maximum(), val, sliderMax - sliderMin, + opt.upsideDown) + return pixel + + # When calculating conversions to/from pixel space, not all of the slider's + # width is actually usable, because the slider handle has a nonzero width. + # We use this function as a helper when the axis needs slider information. + def handleWidth(self): + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderHandle, + self) + return sr.width() + + def effectiveWidth(self): + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderGroove, + self) + sliderLength = self.handleWidth() + sliderMin = gr.x() + sliderMax = gr.right() - sliderLength + 1 + return sliderMax - sliderMin + + # If groove and axis are not aligned (and they should be), we can use + # this function to calculate the offset between them. + def grooveX(self): + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderGroove, + self) + return gr.x() + + def handleMousePress(self, pos, control, val, handle): + opt = QtWidgets.QStyleOptionSlider() + self.initHandleStyleOption(opt, handle) + oldControl = control + control = self.style().hitTestComplexControl( + QtWidgets.QStyle.CC_Slider, opt, pos, self) + sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderHandle, + self) + if control == QtWidgets.QStyle.SC_SliderHandle: + # no pick()- slider orientation static + self.offset = pos.x() - sr.topLeft().x() + self.lastPressed = handle + self.setSliderDown(True) + self.selectedHandle = handle + # emit + + # Needed? + if control != oldControl: + self.update(sr) + return control + + def drawHandle(self, painter, handle): + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + self.initHandleStyleOption(opt, handle) + opt.subControls = QtWidgets.QStyle.SC_SliderHandle + painter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt) + + # def triggerAction(self, action, slider): + # if action == QtWidgets.QAbstractSlider.SliderSingleStepAdd: + # if + + def setLowerValue(self, val): + self.setSpan(val, self.maxVal) + + def setUpperValue(self, val): + self.setSpan(self.minVal, val) + + def setSpan(self, lower, upper): + def bound(min, curr, max): + if curr < min: + return min + elif curr > max: + return max + else: + return curr + + low = bound(self.minimum(), lower, self.maximum()) + high = bound(self.minimum(), upper, self.maximum()) + + if low != self.minVal or high != self.maxVal: + if low != self.minVal: + self.minVal = low + self.minPos = low + # emit + if high != self.maxVal: + self.maxVal = high + self.maxPos = high + # emit + # emit spanChanged + self.update() + + def setLowerPosition(self, val): + if val != self.minPos: + self.minPos = val + if not self.hasTracking(): + self.update() + if self.isSliderDown(): + self.sigMinMoved.emit(self.minPos) + if self.hasTracking() and not self.blockTracking: + self.setLowerValue(val) + + def setUpperPosition(self, val): + if val != self.maxPos: + self.maxPos = val + if not self.hasTracking(): + self.update() + if self.isSliderDown(): + self.sigMaxMoved.emit(self.maxPos) + if self.hasTracking() and not self.blockTracking: + self.setUpperValue(val) + + def mousePressEvent(self, ev): + if self.minimum() == self.maximum() or (ev.buttons() ^ ev.button()): + ev.ignore() + return + + # Prefer maxVal in the default case. + self.upperPressed = self.handleMousePress( + ev.pos(), self.upperPressed, self.maxVal, ScanSlider.maxSlider) + if self.upperPressed != QtWidgets.QStyle.SC_SliderHandle: + self.lowerPressed = self.handleMousePress( + ev.pos(), self.upperPressed, self.minVal, ScanSlider.minSlider) + + # State that is needed to handle the case where two sliders are equal. + self.firstMovement = True + ev.accept() + + def mouseMoveEvent(self, ev): + if (self.lowerPressed != QtWidgets.QStyle.SC_SliderHandle and + self.upperPressed != QtWidgets.QStyle.SC_SliderHandle): + ev.ignore() + return + + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + + # This code seems to be needed so that returning the slider to the + # previous position is honored if a drag distance is exceeded. + m = self.style().pixelMetric(QtWidgets.QStyle.PM_MaximumDragDistance, + opt, self) + newPos = self.pixelPosToRangeValue(ev.pos().x() - self.offset) + + if m >= 0: + r = self.rect().adjusted(-m, -m, m, m) + if not r.contains(ev.pos()): + newPos = self.position + + if self.firstMovement: + if self.minPos == self.maxPos: + # MaxSlider is preferred, except in the case where min == max + # possible value the slider can take. + if self.minPos == self.maximum(): + self.lowerPressed = QtWidgets.QStyle.SC_SliderHandle + self.upperPressed = QtWidgets.QStyle.SC_None + self.firstMovement = False + + if self.lowerPressed == QtWidgets.QStyle.SC_SliderHandle: + newPos = min(newPos, self.maxVal) + self.setLowerPosition(newPos) + + if self.upperPressed == QtWidgets.QStyle.SC_SliderHandle: + newPos = max(newPos, self.minVal) + self.setUpperPosition(newPos) + + ev.accept() + + def mouseReleaseEvent(self, ev): + QtWidgets.QSlider.mouseReleaseEvent(self, ev) + self.setSliderDown(False) # AbstractSlider needs this + self.lowerPressed = QtWidgets.QStyle.SC_None + self.upperPressed = QtWidgets.QStyle.SC_None + + def paintEvent(self, ev): + # Use QStylePainters to make redrawing as painless as possible. + painter = QtWidgets.QStylePainter(self) + # Paint on the custom widget, using the attributes of the fake + # slider references we keep around. setStyleSheet within paintEvent + # leads to heavy performance penalties (and recursion?). + # QPalettes would be nicer to use, since palette entries can be set + # individually for each slider handle, but Windows 7 does not + # use them. This seems to be the only way to override the colors + # regardless of platform. + minPainter = QtWidgets.QStylePainter(self, self.dummyMinSlider) + maxPainter = QtWidgets.QStylePainter(self, self.dummyMaxSlider) + + # Groove + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + opt.sliderValue = 0 + opt.sliderPosition = 0 + opt.subControls = QtWidgets.QStyle.SC_SliderGroove + painter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt) + + # Handles + self.drawHandle(minPainter, ScanSlider.minSlider) + self.drawHandle(maxPainter, ScanSlider.maxSlider) + + +# real (Sliders) => pixel (one pixel movement of sliders would increment by X) +# => range (minimum granularity that sliders understand). +class ScanProxy(QtCore.QObject): + sigMinMoved = QtCore.pyqtSignal(float) + sigMaxMoved = QtCore.pyqtSignal(float) + + def __init__(self, slider, axis, rangeFactor): + QtCore.QObject.__init__(self) + self.axis = axis + self.slider = slider + self.realMin = 0 + self.realMax = 0 + self.numPoints = 10 + self.rangeFactor = rangeFactor + + # Transform that maps the spinboxes to a pixel position on the + # axis. 0 to axis.width() exclusive indicate positions which will be + # displayed on the axis. + # Because the axis's width will change when placed within a layout, + # the realToPixelTransform will initially be invalid. It will be set + # properly during the first resizeEvent, with the below transform. + self.realToPixelTransform = self.calculateNewRealToPixel( + -self.axis.width()/2, 1.0) + self.invalidOldSizeExpected = True + self.axis.installEventFilter(self) + + # What real value should map to the axis/slider left? This doesn't depend + # on any public members so we can make decisions about centering during + # resize and zoom events. + def calculateNewRealToPixel(self, targetLeft, targetScale): + return QtGui.QTransform.fromScale(targetScale, 1).translate( + -targetLeft, 0) + + # pixel vals for sliders: 0 to slider_width - 1 + def realToPixel(self, val): + return (QtCore.QPointF(val, 0) * self.realToPixelTransform).x() + + # Get a point from pixel units to what the sliders display. + def pixelToReal(self, val): + (revXform, invertible) = self.realToPixelTransform.inverted() + if not invertible: + revXform = (QtGui.QTransform.fromTranslate( + -self.realToPixelTransform.dx(), 0) * + QtGui.QTransform.fromScale( + 1/self.realToPixelTransform.m11(), 0)) + realPoint = QtCore.QPointF(val, 0) * revXform + return realPoint.x() + + def rangeToReal(self, val): + # gx = self.slider.grooveX() + # ax = self.axis.x() + # assert gx == ax, "gx: {}, ax: {}".format(gx, ax) + pixelVal = self.slider.rangeValueToPixelPos(val) + return self.pixelToReal(pixelVal) + + def realToRange(self, val): + pixelVal = self.realToPixel(val) + return self.slider.pixelPosToRangeValue(pixelVal) + + def moveMax(self, val): + sliderX = self.realToRange(val) + self.slider.setUpperPosition(sliderX) + self.realMax = val + + def moveMin(self, val): + sliderX = self.realToRange(val) + self.slider.setLowerPosition(sliderX) + self.realMin = val + + def handleMaxMoved(self, rangeVal): + self.sigMaxMoved.emit(self.rangeToReal(rangeVal)) + + def handleMinMoved(self, rangeVal): + self.sigMinMoved.emit(self.rangeToReal(rangeVal)) + + def handleZoom(self, zoomFactor, mouseXPos): + newScale = self.realToPixelTransform.m11() * zoomFactor + refReal = self.pixelToReal(mouseXPos) + newLeft = refReal - mouseXPos/newScale + self.realToPixelTransform = self.calculateNewRealToPixel( + newLeft, newScale) + self.moveMax(self.realMax) + self.moveMin(self.realMin) + + def zoomToFit(self): + currRangeReal = abs(self.realMax - self.realMin) + assert self.rangeFactor > 2 + proportion = self.rangeFactor/(self.rangeFactor - 2) + newScale = self.slider.effectiveWidth()/(proportion*currRangeReal) + newLeft = self.realMin - self.slider.effectiveWidth() \ + / (self.rangeFactor*newScale) + self.realToPixelTransform = self.calculateNewRealToPixel( + newLeft, newScale) + self.printTransform() + self.moveMax(self.realMax) + self.moveMin(self.realMin) + self.axis.update() + + def fitToView(self): + lowRange = 1.0/self.rangeFactor + highRange = (self.rangeFactor - 1)/self.rangeFactor + newMin = self.pixelToReal(lowRange * self.slider.effectiveWidth()) + newMax = self.pixelToReal(highRange * self.slider.effectiveWidth()) + sliderRange = self.slider.maximum() - self.slider.minimum() + assert sliderRange > 0 + self.moveMin(newMin) + self.moveMax(newMax) + # Signals won't fire unless slider was actually grabbed, so + # manually update so the spinboxes know that knew values were set. + # self.realMax/Min will be updated as a consequence of ValueChanged + # signal in spinboxes. + self.sigMaxMoved.emit(newMax) + self.sigMinMoved.emit(newMin) + + def eventFilter(self, obj, ev): + if obj != self.axis: + return False + if ev.type() != QtCore.QEvent.Resize: + return False + oldLeft = self.pixelToReal(0) + if ev.oldSize().isValid(): + refWidth = ev.oldSize().width() - self.slider.handleWidth() + refRight = self.pixelToReal(refWidth) + newWidth = ev.size().width() - self.slider.handleWidth() + assert refRight > oldLeft + newScale = newWidth/(refRight - oldLeft) + else: + # TODO: self.axis.width() is invalid during object + # construction. The width will change when placed in a + # layout WITHOUT a resizeEvent. Why? + oldLeft = -ev.size().width()/2 + newScale = 1.0 + self.invalidOldSizeExpected = False + self.realToPixelTransform = self.calculateNewRealToPixel( + oldLeft, newScale) + # assert self.pixelToReal(0) == oldLeft, \ + # "{}, {}".format(self.pixelToReal(0), oldLeft) + # Slider will update independently, making sure that the old + # slider positions are preserved. Because of this, we can be + # confident that the new slider position will still map to the + # same positions in the new axis-space. + return False + + def printTransform(self): + print("m11: {}, dx: {}".format( + self.realToPixelTransform.m11(), self.realToPixelTransform.dx())) + (inverted, invertible) = self.realToPixelTransform.inverted() + print("m11: {}, dx: {}, singular: {}".format( + inverted.m11(), inverted.dx(), not invertible)) + + +class ScanWidget(QtWidgets.QWidget): + sigMinMoved = QtCore.pyqtSignal(float) + sigMaxMoved = QtCore.pyqtSignal(float) + + def __init__(self, zoomFactor=1.05, rangeFactor=6): + QtWidgets.QWidget.__init__(self) + slider = ScanSlider() + axis = ScanAxis(zoomFactor) + zoomFitButton = QtWidgets.QPushButton("View Range") + fitViewButton = QtWidgets.QPushButton("Snap Range") + self.proxy = ScanProxy(slider, axis, rangeFactor) + axis.proxy = self.proxy + + # Layout. + layout = QtWidgets.QGridLayout() + # Default size will cause axis to disappear otherwise. + layout.setRowMinimumHeight(0, 40) + layout.addWidget(axis, 0, 0, 1, -1) + layout.addWidget(slider, 1, 0, 1, -1) + layout.addWidget(zoomFitButton, 2, 0) + layout.addWidget(fitViewButton, 2, 1) + self.setLayout(layout) + + # Connect signals + slider.sigMaxMoved.connect(self.proxy.handleMaxMoved) + slider.sigMinMoved.connect(self.proxy.handleMinMoved) + self.proxy.sigMaxMoved.connect(self.sigMaxMoved) + self.proxy.sigMinMoved.connect(self.sigMinMoved) + axis.sigZoom.connect(self.proxy.handleZoom) + fitViewButton.clicked.connect(self.fitToView) + zoomFitButton.clicked.connect(self.zoomToFit) + + # Connect event observers. + + # Spinbox and button slots. Any time the spinboxes change, ScanWidget + # mirrors it and passes the information to the proxy. + def setMax(self, val): + self.proxy.moveMax(val) + + def setMin(self, val): + self.proxy.moveMin(val) + + def setNumPoints(self, val): + pass + + def zoomToFit(self): + self.proxy.zoomToFit() + + def fitToView(self): + self.proxy.fitToView() + + def reset(self): + self.proxy.reset() diff --git a/artiq/gui/scientific_spinbox.py b/artiq/gui/scientific_spinbox.py new file mode 100644 index 000000000..65cbba669 --- /dev/null +++ b/artiq/gui/scientific_spinbox.py @@ -0,0 +1,71 @@ +import re +from PyQt5 import QtGui, QtWidgets + +# after +# http://jdreaver.com/posts/2014-07-28-scientific-notation-spin-box-pyside.html + + +_inf = float("inf") +# Regular expression to find floats. Match groups are the whole string, the +# whole coefficient, the decimal part of the coefficient, and the exponent +# part. +_float_re = re.compile(r"(([+-]?\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?)") + + +def valid_float_string(string): + match = _float_re.search(string) + if match: + return match.groups()[0] == string + return False + + +class FloatValidator(QtGui.QValidator): + def validate(self, string, position): + if valid_float_string(string): + return self.Acceptable, string, position + if string == "" or string[position-1] in "eE.-+": + return self.Intermediate, string, position + return self.Invalid, string, position + + def fixup(self, text): + match = _float_re.search(text) + if match: + return match.groups()[0] + return "" + + +class ScientificSpinBox(QtWidgets.QDoubleSpinBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setMinimum(-_inf) + self.setMaximum(_inf) + self.validator = FloatValidator() + self.setDecimals(20) + + def validate(self, text, position): + return self.validator.validate(text, position) + + def fixup(self, text): + return self.validator.fixup(text) + + def valueFromText(self, text): + return float(text) + + def textFromValue(self, value): + return format_float(value) + + def stepBy(self, steps): + text = self.cleanText() + groups = _float_re.search(text).groups() + decimal = float(groups[1]) + decimal += steps + new_string = "{:g}".format(decimal) + (groups[3] if groups[3] else "") + self.lineEdit().setText(new_string) + + +def format_float(value): + """Modified form of the 'g' format specifier.""" + string = "{:g}".format(value) + string = string.replace("e+", "e") + string = re.sub("e(-?)0*(\d+)", r"e\1\2", string) + return string diff --git a/artiq/gui/ticker.py b/artiq/gui/ticker.py new file mode 100644 index 000000000..0fb56a70a --- /dev/null +++ b/artiq/gui/ticker.py @@ -0,0 +1,136 @@ +# Robert Jordens , 2016 + +import numpy as np + + +class Ticker: + # TODO: if this turns out to be computationally expensive, then refactor + # such that the log()s and intermediate values are reused. But + # probably the string formatting itself is the limiting factor here. + def __init__(self, min_ticks=3, precision=3, steps=(5, 2, 1, .5)): + """ + min_ticks: minimum number of ticks to generate + The maximum number of ticks is + max(consecutive ratios in steps)*min_ticks + thus 5/2*min_ticks for default steps. + precision: maximum number of significant digits in labels + Also extract common offset and magnitude from ticks + if dynamic range exceeds precision number of digits + (small range on top of large offset). + steps: tick increments at a given magnitude + The .5 catches rounding errors where the calculation + of step_magnitude falls into the wrong exponent bin. + """ + self.min_ticks = min_ticks + self.precision = precision + self.steps = steps + + def step(self, i): + """ + Return recommended step value for interval size `i`. + """ + if not i: + raise ValueError("Need a finite interval") + step = i/self.min_ticks # rational step size for min_ticks + step_magnitude = 10**np.floor(np.log10(step)) + # underlying magnitude for steps + for m in self.steps: + good_step = m*step_magnitude + if good_step <= step: + return good_step + + def ticks(self, a, b): + """ + Return recommended tick values for interval `[a, b[`. + """ + step = self.step(b - a) + a0 = np.ceil(a/step)*step + ticks = np.arange(a0, b, step) + return ticks + + def offset(self, a, step): + """ + Find offset if dynamic range of the interval is large + (small range on large offset). + + If offset is finite, show `offset + value`. + """ + if a == 0.: + return 0. + la = np.floor(np.log10(abs(a))) + lr = np.floor(np.log10(step)) + if la - lr < self.precision: + return 0. + magnitude = 10**(lr - 1 + self.precision) + offset = np.floor(a/magnitude)*magnitude + return offset + + def magnitude(self, a, b, step): + """ + Determine the scaling magnitude. + + If magnitude differs from unity, show `magnitude * value`. + This depends on proper offsetting by `offset()`. + """ + v = np.floor(np.log10(max(abs(a), abs(b)))) + w = np.floor(np.log10(step)) + if v < self.precision and w > -self.precision: + return 1. + return 10**v + + def fix_minus(self, s): + return s.replace("-", "−") # unicode minus + + def format(self, step): + """ + Determine format string to represent step sufficiently accurate. + """ + dynamic = -int(np.floor(np.log10(step))) + dynamic = min(max(0, dynamic), self.precision) + return "{{:1.{:d}f}}".format(dynamic) + + def compact_exponential(self, v): + """ + Format `v` in in compact exponential, stripping redundant elements + (pluses, leading and trailing zeros and decimal point, trailing `e`). + """ + # this is after the matplotlib ScalarFormatter + # without any i18n + significand, exponent = "{:1.10e}".format(v).split("e") + significand = significand.rstrip("0").rstrip(".") + exponent_sign = exponent[0].replace("+", "") + exponent = exponent[1:].lstrip("0") + s = "{:s}e{:s}{:s}".format(significand, exponent_sign, + exponent).rstrip("e") + return self.fix_minus(s) + + def prefix(self, offset, magnitude): + """ + Stringify `offset` and `magnitude`. + + Expects the string to be shown top/left of the value it refers to. + """ + prefix = "" + if offset != 0.: + prefix += self.compact_exponential(offset) + " + " + if magnitude != 1.: + prefix += self.compact_exponential(magnitude) + " × " + return prefix + + def __call__(self, a, b): + """ + Determine ticks, prefix and labels given the interval + `[a, b[`. + + Return tick values, prefix string to be show to the left or + above the labels, and tick labels. + """ + ticks = self.ticks(a, b) + offset = self.offset(a, ticks[1] - ticks[0]) + t = ticks - offset + magnitude = self.magnitude(t[0], t[-1], t[1] - t[0]) + t /= magnitude + prefix = self.prefix(offset, magnitude) + format = self.format(t[1] - t[0]) + labels = [self.fix_minus(format.format(t)) for t in t] + return ticks, prefix, labels