Date axis item (#1154)
* Add DateAxisItem
* Change style to camelCase
* Fix missing first tick for negative timestamps
* Add ms precision, auto skipping
Auto skipping allows a zoom level to skip ticks automatically if the
maximum number of ticks/pt is exceeded
* fixes suggested by @goetzc
* workaround for negative argument to utcfromtimestamp on windows
* attachToPlotItem method
* default date axis orientation
* Use new DateAxisItem in Plot Customization example
* attachToPlotItem bugfix
* examples of DateAxisItem
* modified description of customPlot example
* added descriptions to the new examples, reformatted their code, included the first one into utils.py
* typo
* Refactored code for setting axis items into new function
Replaces "DateAxisItem.attachToPlotItem"
* Fix string comparison with ==
* Doc: Slightly more text for DateAxisItem, small improvement for PlotItem
* Make PlotWidget.setAxisItems official
* Fix typo in docstring
* renamed an example
* merge bug fix
* Revert "merge bug fix"
This reverts commit 876b5a7cdb
.
* Real bug fix
* support for dates upto -1e13..1e13
* Automatically limit DateAxisItem to a range from -1e12 to 1e12 years
Very large years (|y|>1e13) cause infinite loop, and since nobody
needs time 100 times larger than the age of the universe anyways,
this constrains it to 1e12.
Following suggestion by @axil:
https://github.com/pyqtgraph/pyqtgraph/pull/1154#issuecomment-612662168
* Also catch ValueErrors occuring on Linux before OverfloeErrors
While zooming out, before hitting OverflowErrors, utctimestamp
produces ValueErrors (at least on my Linux machine), so they
are also catched.
* Fix: Timestamp 0 corresponds to year 1970
For large years, x axis labels jump by 1970 years if it is not
accounted for timestamp 0 to be equal to year 1970.
* Fix: When zooming into extreme dates, OSError occurs
This commit catches the OSError like the other observed errors
* Disable stepping below years for dates outside *_REGULAR_TIMESTAMP
2 reasons: First: At least on my Linux machine, zooming into
those dates creates infinite loops. Second: Nobody needs
sub-year-precision for those extreme years anyways.
* Adapt zoom level sizes based on current font size and screen resolution
This is somewhat experimental. With this commit, no longer 60 px are
assumed as width for all zoom levels, but the current font and
display resolution are considered to calculate the width of ticks in
each zoom level. See the new function `updateZoomLevels` for
details.
Before calling this function, overridden functions `paint` and
`generateDrawSpecs` provide information over the current display
and font via `self.fontScaleFactor` and `self.fontMetrics`.
* Meaningful error meassage when adding axis to multiple PlotItems
As @axil noted in the DateAxisItem PR, currently users get a
segmentation fault when one tries to add an axis to multiple
PlotItems. This commit adds a meaningful RuntimeError message
for that case.
* setZoomLevelForDensity: Refactoring and calculating optimal spacing on the fly
* DateTimeAxis Fix: 1970 shows when zooming far out
* Refactoring: Make zoomLevels a customizable dict again
* updated the dateaxisitem example
* Fix: Get screen resolution in a way that also works for Qt 4
This is both a simplification in code and an improvement in backwards compatibility with Qt 4.
* DateAxisItem Fix: Also resolve time below 0.5 seconds
* unix line endings in examples
* DateTimeAxis Fix: For years < 1 and > 9999, stepping broke
Stepping was off by 1970 years for years < 1 and > 9999,
resulting in a gap in ticks visible when zooming out. Fixed by
subtracting the usual 1970 years.
* DateTimeAxis Fix: Zooming out too far causes infinite loop
Fixed by setting default limits to +/- 1e10 years. Should still
be enough.
* improved second dateaxisitem example
* 1..9999 years limit
* DateTimeAxis: Use OrderedDict to stay compatible with Python < 3-6
* DateAxisItem: Use font height to determine spacing for vertical axes
* window title
* added dateaxisitem.rst
* updated index.rst
Co-authored-by: Lukas Heiniger <lukas.heiniger@sed.ethz.ch>
Co-authored-by: Lev Maximov <lev.maximov@gmail.com>
Co-authored-by: 2xB <2xB@users.noreply.github.com>
This commit is contained in:
parent
a2053b13d0
commit
a76d9daec2
8
doc/source/graphicsItems/dateaxisitem.rst
Normal file
8
doc/source/graphicsItems/dateaxisitem.rst
Normal file
@ -0,0 +1,8 @@
|
||||
DateAxisItem
|
||||
============
|
||||
|
||||
.. autoclass:: pyqtgraph.DateAxisItem
|
||||
:members:
|
||||
|
||||
.. automethod:: pyqtgraph.DateAxisItem.__init__
|
||||
|
@ -43,3 +43,4 @@ Contents:
|
||||
graphicsitem
|
||||
uigraphicsitem
|
||||
graphicswidgetanchor
|
||||
dateaxisitem
|
||||
|
@ -2,6 +2,7 @@ files = """ArrowItem
|
||||
AxisItem
|
||||
ButtonItem
|
||||
CurvePoint
|
||||
DateAxisItem
|
||||
GradientEditorItem
|
||||
GradientLegend
|
||||
GraphicsLayout
|
||||
|
33
examples/DateAxisItem.py
Normal file
33
examples/DateAxisItem.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""
|
||||
Demonstrates the usage of DateAxisItem to display properly-formatted
|
||||
timestamps on x-axis which automatically adapt to current zoom level.
|
||||
|
||||
"""
|
||||
import initExample ## Add path to library (just for examples; you do not need this)
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph.Qt import QtGui
|
||||
|
||||
app = QtGui.QApplication([])
|
||||
|
||||
# Create a plot with a date-time axis
|
||||
w = pg.PlotWidget(axisItems = {'bottom': pg.DateAxisItem()})
|
||||
w.showGrid(x=True, y=True)
|
||||
|
||||
# Plot sin(1/x^2) with timestamps in the last 100 years
|
||||
now = time.time()
|
||||
x = np.linspace(2*np.pi, 1000*2*np.pi, 8301)
|
||||
w.plot(now-(2*np.pi/x)**2*100*np.pi*1e7, np.sin(x), symbol='o')
|
||||
|
||||
w.setWindowTitle('pyqtgraph example: DateAxisItem')
|
||||
w.show()
|
||||
|
||||
## Start Qt event loop unless running in interactive mode or using pyside.
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'):
|
||||
app.exec_()
|
48
examples/DateAxisItem_QtDesigner.py
Normal file
48
examples/DateAxisItem_QtDesigner.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""
|
||||
Demonstrates the usage of DateAxisItem in a layout created with Qt Designer.
|
||||
|
||||
The spotlight here is on the 'setAxisItems' method, without which
|
||||
one would have to subclass plotWidget in order to attach a dateaxis to it.
|
||||
|
||||
"""
|
||||
import initExample ## Add path to library (just for examples; you do not need this)
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from PyQt5 import QtWidgets, QtCore, uic
|
||||
import pyqtgraph as pg
|
||||
|
||||
pg.setConfigOption('background', 'w')
|
||||
pg.setConfigOption('foreground', 'k')
|
||||
|
||||
BLUE = pg.mkPen('#1f77b4')
|
||||
|
||||
Design, _ = uic.loadUiType('DateAxisItem_QtDesigner.ui')
|
||||
|
||||
class ExampleApp(QtWidgets.QMainWindow, Design):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setupUi(self)
|
||||
now = time.time()
|
||||
# Plot random values with timestamps in the last 6 months
|
||||
timestamps = np.linspace(now - 6*30*24*3600, now, 100)
|
||||
self.curve = self.plotWidget.plot(x=timestamps, y=np.random.rand(100),
|
||||
symbol='o', symbolSize=5, pen=BLUE)
|
||||
# 'o' circle 't' triangle 'd' diamond '+' plus 's' square
|
||||
self.plotWidget.setAxisItems({'bottom': pg.DateAxisItem()})
|
||||
self.plotWidget.showGrid(x=True, y=True)
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
app.setStyle(QtWidgets.QStyleFactory.create('Fusion'))
|
||||
app.setPalette(QtWidgets.QApplication.style().standardPalette())
|
||||
window = ExampleApp()
|
||||
window.setWindowTitle('pyqtgraph example: DateAxisItem_QtDesigner')
|
||||
window.show()
|
||||
|
||||
## Start Qt event loop unless running in interactive mode or using pyside.
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'):
|
||||
app.exec_()
|
44
examples/DateAxisItem_QtDesigner.ui
Normal file
44
examples/DateAxisItem_QtDesigner.ui
Normal file
@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>536</width>
|
||||
<height>381</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="PlotWidget" name="plotWidget"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>536</width>
|
||||
<height>18</height>
|
||||
</rect>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>PlotWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
This example demonstrates the creation of a plot with a customized
|
||||
AxisItem and ViewBox.
|
||||
This example demonstrates the creation of a plot with
|
||||
DateAxisItem and a customized ViewBox.
|
||||
"""
|
||||
|
||||
|
||||
@ -12,40 +12,6 @@ from pyqtgraph.Qt import QtCore, QtGui
|
||||
import numpy as np
|
||||
import time
|
||||
|
||||
class DateAxis(pg.AxisItem):
|
||||
def tickStrings(self, values, scale, spacing):
|
||||
strns = []
|
||||
rng = max(values)-min(values)
|
||||
#if rng < 120:
|
||||
# return pg.AxisItem.tickStrings(self, values, scale, spacing)
|
||||
if rng < 3600*24:
|
||||
string = '%H:%M:%S'
|
||||
label1 = '%b %d -'
|
||||
label2 = ' %b %d, %Y'
|
||||
elif rng >= 3600*24 and rng < 3600*24*30:
|
||||
string = '%d'
|
||||
label1 = '%b - '
|
||||
label2 = '%b, %Y'
|
||||
elif rng >= 3600*24*30 and rng < 3600*24*30*24:
|
||||
string = '%b'
|
||||
label1 = '%Y -'
|
||||
label2 = ' %Y'
|
||||
elif rng >=3600*24*30*24:
|
||||
string = '%Y'
|
||||
label1 = ''
|
||||
label2 = ''
|
||||
for x in values:
|
||||
try:
|
||||
strns.append(time.strftime(string, time.localtime(x)))
|
||||
except ValueError: ## Windows can't handle dates before 1970
|
||||
strns.append('')
|
||||
try:
|
||||
label = time.strftime(label1, time.localtime(min(values)))+time.strftime(label2, time.localtime(max(values)))
|
||||
except ValueError:
|
||||
label = ''
|
||||
#self.setLabel(text=label)
|
||||
return strns
|
||||
|
||||
class CustomViewBox(pg.ViewBox):
|
||||
def __init__(self, *args, **kwds):
|
||||
pg.ViewBox.__init__(self, *args, **kwds)
|
||||
@ -65,10 +31,10 @@ class CustomViewBox(pg.ViewBox):
|
||||
|
||||
app = pg.mkQApp()
|
||||
|
||||
axis = DateAxis(orientation='bottom')
|
||||
axis = pg.DateAxisItem(orientation='bottom')
|
||||
vb = CustomViewBox()
|
||||
|
||||
pw = pg.PlotWidget(viewBox=vb, axisItems={'bottom': axis}, enableMenu=False, title="PlotItem with custom axis and ViewBox<br>Menu disabled, mouse behavior changed: left-drag to zoom, right-click to reset zoom")
|
||||
pw = pg.PlotWidget(viewBox=vb, axisItems={'bottom': axis}, enableMenu=False, title="PlotItem with DateAxisItem and custom ViewBox<br>Menu disabled, mouse behavior changed: left-drag to zoom, right-click to reset zoom")
|
||||
dates = np.arange(8) * (3600*24*356)
|
||||
pw.plot(x=dates, y=[1,6,2,4,3,5,6,8], symbol='o')
|
||||
pw.show()
|
||||
|
@ -14,6 +14,7 @@ examples = OrderedDict([
|
||||
('Crosshair / Mouse interaction', 'crosshair.py'),
|
||||
('Data Slicing', 'DataSlicing.py'),
|
||||
('Plot Customization', 'customPlot.py'),
|
||||
('Timestamps on x axis', 'DateAxisItem.py'),
|
||||
('Image Analysis', 'imageAnalysis.py'),
|
||||
('ViewBox Features', 'ViewBoxFeatures.py'),
|
||||
('Dock widgets', 'dockarea.py'),
|
||||
|
@ -219,6 +219,7 @@ from .graphicsItems.ViewBox import *
|
||||
from .graphicsItems.ArrowItem import *
|
||||
from .graphicsItems.ImageItem import *
|
||||
from .graphicsItems.AxisItem import *
|
||||
from .graphicsItems.DateAxisItem import *
|
||||
from .graphicsItems.LabelItem import *
|
||||
from .graphicsItems.CurvePoint import *
|
||||
from .graphicsItems.GraphicsWidgetAnchor import *
|
||||
|
@ -507,20 +507,29 @@ class AxisItem(GraphicsWidget):
|
||||
|
||||
def linkToView(self, view):
|
||||
"""Link this axis to a ViewBox, causing its displayed range to match the visible range of the view."""
|
||||
oldView = self.linkedView()
|
||||
self.unlinkFromView()
|
||||
|
||||
self._linkedView = weakref.ref(view)
|
||||
if self.orientation in ['right', 'left']:
|
||||
view.sigYRangeChanged.connect(self.linkedViewChanged)
|
||||
else:
|
||||
view.sigXRangeChanged.connect(self.linkedViewChanged)
|
||||
|
||||
view.sigResized.connect(self.linkedViewChanged)
|
||||
|
||||
def unlinkFromView(self):
|
||||
"""Unlink this axis from a ViewBox."""
|
||||
oldView = self.linkedView()
|
||||
self._linkedView = None
|
||||
if self.orientation in ['right', 'left']:
|
||||
if oldView is not None:
|
||||
oldView.sigYRangeChanged.disconnect(self.linkedViewChanged)
|
||||
view.sigYRangeChanged.connect(self.linkedViewChanged)
|
||||
else:
|
||||
if oldView is not None:
|
||||
oldView.sigXRangeChanged.disconnect(self.linkedViewChanged)
|
||||
view.sigXRangeChanged.connect(self.linkedViewChanged)
|
||||
|
||||
if oldView is not None:
|
||||
oldView.sigResized.disconnect(self.linkedViewChanged)
|
||||
view.sigResized.connect(self.linkedViewChanged)
|
||||
|
||||
def linkedViewChanged(self, view, newRange=None):
|
||||
if self.orientation in ['right', 'left']:
|
||||
|
319
pyqtgraph/graphicsItems/DateAxisItem.py
Normal file
319
pyqtgraph/graphicsItems/DateAxisItem.py
Normal file
@ -0,0 +1,319 @@
|
||||
import sys
|
||||
import numpy as np
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .AxisItem import AxisItem
|
||||
from ..pgcollections import OrderedDict
|
||||
|
||||
__all__ = ['DateAxisItem', 'ZoomLevel']
|
||||
|
||||
MS_SPACING = 1/1000.0
|
||||
SECOND_SPACING = 1
|
||||
MINUTE_SPACING = 60
|
||||
HOUR_SPACING = 3600
|
||||
DAY_SPACING = 24 * HOUR_SPACING
|
||||
WEEK_SPACING = 7 * DAY_SPACING
|
||||
MONTH_SPACING = 30 * DAY_SPACING
|
||||
YEAR_SPACING = 365 * DAY_SPACING
|
||||
|
||||
if sys.platform == 'win32':
|
||||
_epoch = datetime.utcfromtimestamp(0)
|
||||
def utcfromtimestamp(timestamp):
|
||||
return _epoch + timedelta(seconds=timestamp)
|
||||
else:
|
||||
utcfromtimestamp = datetime.utcfromtimestamp
|
||||
|
||||
MIN_REGULAR_TIMESTAMP = (datetime(1, 1, 1) - datetime(1970,1,1)).total_seconds()
|
||||
MAX_REGULAR_TIMESTAMP = (datetime(9999, 1, 1) - datetime(1970,1,1)).total_seconds()
|
||||
SEC_PER_YEAR = 365.25*24*3600
|
||||
|
||||
def makeMSStepper(stepSize):
|
||||
def stepper(val, n):
|
||||
if val < MIN_REGULAR_TIMESTAMP or val > MAX_REGULAR_TIMESTAMP:
|
||||
return np.inf
|
||||
|
||||
val *= 1000
|
||||
f = stepSize * 1000
|
||||
return (val // (n*f) + 1) * (n*f) / 1000.0
|
||||
return stepper
|
||||
|
||||
def makeSStepper(stepSize):
|
||||
def stepper(val, n):
|
||||
if val < MIN_REGULAR_TIMESTAMP or val > MAX_REGULAR_TIMESTAMP:
|
||||
return np.inf
|
||||
|
||||
return (val // (n*stepSize) + 1) * (n*stepSize)
|
||||
return stepper
|
||||
|
||||
def makeMStepper(stepSize):
|
||||
def stepper(val, n):
|
||||
if val < MIN_REGULAR_TIMESTAMP or val > MAX_REGULAR_TIMESTAMP:
|
||||
return np.inf
|
||||
|
||||
d = utcfromtimestamp(val)
|
||||
base0m = (d.month + n*stepSize - 1)
|
||||
d = datetime(d.year + base0m // 12, base0m % 12 + 1, 1)
|
||||
return (d - datetime(1970, 1, 1)).total_seconds()
|
||||
return stepper
|
||||
|
||||
def makeYStepper(stepSize):
|
||||
def stepper(val, n):
|
||||
if val < MIN_REGULAR_TIMESTAMP or val > MAX_REGULAR_TIMESTAMP:
|
||||
return np.inf
|
||||
|
||||
d = utcfromtimestamp(val)
|
||||
next_year = (d.year // (n*stepSize) + 1) * (n*stepSize)
|
||||
if next_year > 9999:
|
||||
return np.inf
|
||||
next_date = datetime(next_year, 1, 1)
|
||||
return (next_date - datetime(1970, 1, 1)).total_seconds()
|
||||
return stepper
|
||||
|
||||
class TickSpec:
|
||||
""" Specifies the properties for a set of date ticks and computes ticks
|
||||
within a given utc timestamp range """
|
||||
def __init__(self, spacing, stepper, format, autoSkip=None):
|
||||
"""
|
||||
============= ==========================================================
|
||||
Arguments
|
||||
spacing approximate (average) tick spacing
|
||||
stepper a stepper function that takes a utc time stamp and a step
|
||||
steps number n to compute the start of the next unit. You
|
||||
can use the make_X_stepper functions to create common
|
||||
steppers.
|
||||
format a strftime compatible format string which will be used to
|
||||
convert tick locations to date/time strings
|
||||
autoSkip list of step size multipliers to be applied when the tick
|
||||
density becomes too high. The tick spec automatically
|
||||
applies additional powers of 10 (10, 100, ...) to the list
|
||||
if necessary. Set to None to switch autoSkip off
|
||||
============= ==========================================================
|
||||
|
||||
"""
|
||||
self.spacing = spacing
|
||||
self.step = stepper
|
||||
self.format = format
|
||||
self.autoSkip = autoSkip
|
||||
|
||||
def makeTicks(self, minVal, maxVal, minSpc):
|
||||
ticks = []
|
||||
n = self.skipFactor(minSpc)
|
||||
x = self.step(minVal, n)
|
||||
while x <= maxVal:
|
||||
ticks.append(x)
|
||||
x = self.step(x, n)
|
||||
return (np.array(ticks), n)
|
||||
|
||||
def skipFactor(self, minSpc):
|
||||
if self.autoSkip is None or minSpc < self.spacing:
|
||||
return 1
|
||||
factors = np.array(self.autoSkip, dtype=np.float)
|
||||
while True:
|
||||
for f in factors:
|
||||
spc = self.spacing * f
|
||||
if spc > minSpc:
|
||||
return int(f)
|
||||
factors *= 10
|
||||
|
||||
|
||||
class ZoomLevel:
|
||||
""" Generates the ticks which appear in a specific zoom level """
|
||||
def __init__(self, tickSpecs, exampleText):
|
||||
"""
|
||||
============= ==========================================================
|
||||
tickSpecs a list of one or more TickSpec objects with decreasing
|
||||
coarseness
|
||||
============= ==========================================================
|
||||
|
||||
"""
|
||||
self.tickSpecs = tickSpecs
|
||||
self.utcOffset = 0
|
||||
self.exampleText = exampleText
|
||||
|
||||
def tickValues(self, minVal, maxVal, minSpc):
|
||||
# return tick values for this format in the range minVal, maxVal
|
||||
# the return value is a list of tuples (<avg spacing>, [tick positions])
|
||||
# minSpc indicates the minimum spacing (in seconds) between two ticks
|
||||
# to fullfill the maxTicksPerPt constraint of the DateAxisItem at the
|
||||
# current zoom level. This is used for auto skipping ticks.
|
||||
allTicks = []
|
||||
valueSpecs = []
|
||||
# back-project (minVal maxVal) to UTC, compute ticks then offset to
|
||||
# back to local time again
|
||||
utcMin = minVal - self.utcOffset
|
||||
utcMax = maxVal - self.utcOffset
|
||||
for spec in self.tickSpecs:
|
||||
ticks, skipFactor = spec.makeTicks(utcMin, utcMax, minSpc)
|
||||
# reposition tick labels to local time coordinates
|
||||
ticks += self.utcOffset
|
||||
# remove any ticks that were present in higher levels
|
||||
tick_list = [x for x in ticks.tolist() if x not in allTicks]
|
||||
allTicks.extend(tick_list)
|
||||
valueSpecs.append((spec.spacing, tick_list))
|
||||
# if we're skipping ticks on the current level there's no point in
|
||||
# producing lower level ticks
|
||||
if skipFactor > 1:
|
||||
break
|
||||
return valueSpecs
|
||||
|
||||
|
||||
YEAR_MONTH_ZOOM_LEVEL = ZoomLevel([
|
||||
TickSpec(YEAR_SPACING, makeYStepper(1), '%Y', autoSkip=[1, 5, 10, 25]),
|
||||
TickSpec(MONTH_SPACING, makeMStepper(1), '%b')
|
||||
], "YYYY")
|
||||
MONTH_DAY_ZOOM_LEVEL = ZoomLevel([
|
||||
TickSpec(MONTH_SPACING, makeMStepper(1), '%b'),
|
||||
TickSpec(DAY_SPACING, makeSStepper(DAY_SPACING), '%d', autoSkip=[1, 5])
|
||||
], "MMM")
|
||||
DAY_HOUR_ZOOM_LEVEL = ZoomLevel([
|
||||
TickSpec(DAY_SPACING, makeSStepper(DAY_SPACING), '%a %d'),
|
||||
TickSpec(HOUR_SPACING, makeSStepper(HOUR_SPACING), '%H:%M', autoSkip=[1, 6])
|
||||
], "MMM 00")
|
||||
HOUR_MINUTE_ZOOM_LEVEL = ZoomLevel([
|
||||
TickSpec(DAY_SPACING, makeSStepper(DAY_SPACING), '%a %d'),
|
||||
TickSpec(MINUTE_SPACING, makeSStepper(MINUTE_SPACING), '%H:%M',
|
||||
autoSkip=[1, 5, 15])
|
||||
], "MMM 00")
|
||||
HMS_ZOOM_LEVEL = ZoomLevel([
|
||||
TickSpec(SECOND_SPACING, makeSStepper(SECOND_SPACING), '%H:%M:%S',
|
||||
autoSkip=[1, 5, 15, 30])
|
||||
], "99:99:99")
|
||||
MS_ZOOM_LEVEL = ZoomLevel([
|
||||
TickSpec(MINUTE_SPACING, makeSStepper(MINUTE_SPACING), '%H:%M:%S'),
|
||||
TickSpec(MS_SPACING, makeMSStepper(MS_SPACING), '%S.%f',
|
||||
autoSkip=[1, 5, 10, 25])
|
||||
], "99:99:99")
|
||||
|
||||
class DateAxisItem(AxisItem):
|
||||
"""
|
||||
**Bases:** :class:`AxisItem <pyqtgraph.AxisItem>`
|
||||
|
||||
An AxisItem that displays dates from unix timestamps.
|
||||
|
||||
The display format is adjusted automatically depending on the current time
|
||||
density (seconds/point) on the axis. For more details on changing this
|
||||
behaviour, see :func:`setZoomLevelForDensity() <pyqtgraph.DateAxisItem.setZoomLevelForDensity>`.
|
||||
|
||||
Can be added to an existing plot e.g. via
|
||||
:func:`setAxisItems({'bottom':axis}) <pyqtgraph.PlotItem.setAxisItems>`.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, orientation='bottom', **kwargs):
|
||||
"""
|
||||
Create a new DateAxisItem.
|
||||
|
||||
For `orientation` and `**kwargs`, see
|
||||
:func:`AxisItem.__init__ <pyqtgraph.AxisItem.__init__>`.
|
||||
|
||||
"""
|
||||
|
||||
super(DateAxisItem, self).__init__(orientation, **kwargs)
|
||||
# Set the zoom level to use depending on the time density on the axis
|
||||
self.utcOffset = time.timezone
|
||||
|
||||
self.zoomLevels = OrderedDict([
|
||||
(np.inf, YEAR_MONTH_ZOOM_LEVEL),
|
||||
(5 * 3600*24, MONTH_DAY_ZOOM_LEVEL),
|
||||
(6 * 3600, DAY_HOUR_ZOOM_LEVEL),
|
||||
(15 * 60, HOUR_MINUTE_ZOOM_LEVEL),
|
||||
(30, HMS_ZOOM_LEVEL),
|
||||
(1, MS_ZOOM_LEVEL),
|
||||
])
|
||||
|
||||
def tickStrings(self, values, scale, spacing):
|
||||
tickSpecs = self.zoomLevel.tickSpecs
|
||||
tickSpec = next((s for s in tickSpecs if s.spacing == spacing), None)
|
||||
try:
|
||||
dates = [utcfromtimestamp(v - self.utcOffset) for v in values]
|
||||
except (OverflowError, ValueError, OSError):
|
||||
# should not normally happen
|
||||
return ['%g' % ((v-self.utcOffset)//SEC_PER_YEAR + 1970) for v in values]
|
||||
|
||||
formatStrings = []
|
||||
for x in dates:
|
||||
try:
|
||||
s = x.strftime(tickSpec.format)
|
||||
if '%f' in tickSpec.format:
|
||||
# we only support ms precision
|
||||
s = s[:-3]
|
||||
elif '%Y' in tickSpec.format:
|
||||
s = s.lstrip('0')
|
||||
formatStrings.append(s)
|
||||
except ValueError: # Windows can't handle dates before 1970
|
||||
formatStrings.append('')
|
||||
return formatStrings
|
||||
|
||||
def tickValues(self, minVal, maxVal, size):
|
||||
density = (maxVal - minVal) / size
|
||||
self.setZoomLevelForDensity(density)
|
||||
values = self.zoomLevel.tickValues(minVal, maxVal, minSpc=self.minSpacing)
|
||||
return values
|
||||
|
||||
def setZoomLevelForDensity(self, density):
|
||||
"""
|
||||
Setting `zoomLevel` and `minSpacing` based on given density of seconds per pixel
|
||||
|
||||
The display format is adjusted automatically depending on the current time
|
||||
density (seconds/point) on the axis. You can customize the behaviour by
|
||||
overriding this function or setting a different set of zoom levels
|
||||
than the default one. The `zoomLevels` variable is a dictionary with the
|
||||
maximal distance of ticks in seconds which are allowed for each zoom level
|
||||
before the axis switches to the next coarser level. To create custom
|
||||
zoom levels, override this function and provide custom `zoomLevelWidths` and
|
||||
`zoomLevels`.
|
||||
"""
|
||||
padding = 10
|
||||
|
||||
# Size in pixels a specific tick label will take
|
||||
if self.orientation in ['bottom', 'top']:
|
||||
def sizeOf(text):
|
||||
return self.fontMetrics.boundingRect(text).width() + padding*self.fontScaleFactor
|
||||
else:
|
||||
def sizeOf(text):
|
||||
return self.fontMetrics.boundingRect(text).height() + padding*self.fontScaleFactor
|
||||
|
||||
# Fallback zoom level: Years/Months
|
||||
self.zoomLevel = YEAR_MONTH_ZOOM_LEVEL
|
||||
for maximalSpacing, zoomLevel in self.zoomLevels.items():
|
||||
size = sizeOf(zoomLevel.exampleText)
|
||||
|
||||
# Test if zoom level is too fine grained
|
||||
if maximalSpacing/size < density:
|
||||
break
|
||||
|
||||
self.zoomLevel = zoomLevel
|
||||
|
||||
# Set up zoomLevel
|
||||
self.zoomLevel.utcOffset = self.utcOffset
|
||||
|
||||
# Calculate minimal spacing of items on the axis
|
||||
size = sizeOf(self.zoomLevel.exampleText)
|
||||
self.minSpacing = density*size
|
||||
|
||||
def linkToView(self, view):
|
||||
super(DateAxisItem, self).linkToView(view)
|
||||
|
||||
# Set default limits
|
||||
_min = MIN_REGULAR_TIMESTAMP
|
||||
_max = MAX_REGULAR_TIMESTAMP
|
||||
|
||||
if self.orientation in ['right', 'left']:
|
||||
view.setLimits(yMin=_min, yMax=_max)
|
||||
else:
|
||||
view.setLimits(xMin=_min, xMax=_max)
|
||||
|
||||
def generateDrawSpecs(self, p):
|
||||
# Get font metrics from QPainter
|
||||
# Not happening in "paint", as the QPainter p there is a different one from the one here,
|
||||
# so changing that font could cause unwanted side effects
|
||||
if self.tickFont is not None:
|
||||
p.setFont(self.tickFont)
|
||||
|
||||
self.fontMetrics = p.fontMetrics()
|
||||
|
||||
# Get font scale factor by current window resolution
|
||||
self.fontScaleFactor = p.device().logicalDpiX() / 96
|
||||
|
||||
return super(DateAxisItem, self).generateDrawSpecs(p)
|
@ -95,7 +95,7 @@ class PlotItem(GraphicsWidget):
|
||||
def __init__(self, parent=None, name=None, labels=None, title=None, viewBox=None, axisItems=None, enableMenu=True, **kargs):
|
||||
"""
|
||||
Create a new PlotItem. All arguments are optional.
|
||||
Any extra keyword arguments are passed to PlotItem.plot().
|
||||
Any extra keyword arguments are passed to :func:`PlotItem.plot() <pyqtgraph.PlotItem.plot>`.
|
||||
|
||||
============== ==========================================================================================
|
||||
**Arguments:**
|
||||
@ -153,20 +153,9 @@ class PlotItem(GraphicsWidget):
|
||||
|
||||
self.legend = None
|
||||
|
||||
## Create and place axis items
|
||||
if axisItems is None:
|
||||
axisItems = {}
|
||||
# Initialize axis items
|
||||
self.axes = {}
|
||||
for k, pos in (('top', (1,1)), ('bottom', (3,1)), ('left', (2,0)), ('right', (2,2))):
|
||||
if k in axisItems:
|
||||
axis = axisItems[k]
|
||||
else:
|
||||
axis = AxisItem(orientation=k, parent=self)
|
||||
axis.linkToView(self.vb)
|
||||
self.axes[k] = {'item': axis, 'pos': pos}
|
||||
self.layout.addItem(axis, *pos)
|
||||
axis.setZValue(-1000)
|
||||
axis.setFlag(axis.ItemNegativeZStacksBehindParent)
|
||||
self.setAxisItems(axisItems)
|
||||
|
||||
self.titleLabel = LabelItem('', size='11pt', parent=self)
|
||||
self.layout.addItem(self.titleLabel, 0, 1)
|
||||
@ -254,11 +243,6 @@ class PlotItem(GraphicsWidget):
|
||||
self.ctrl.maxTracesCheck.toggled.connect(self.updateDecimation)
|
||||
self.ctrl.maxTracesSpin.valueChanged.connect(self.updateDecimation)
|
||||
|
||||
self.hideAxis('right')
|
||||
self.hideAxis('top')
|
||||
self.showAxis('left')
|
||||
self.showAxis('bottom')
|
||||
|
||||
if labels is None:
|
||||
labels = {}
|
||||
for label in list(self.axes.keys()):
|
||||
@ -300,6 +284,58 @@ class PlotItem(GraphicsWidget):
|
||||
locals()[m] = _create_method(m)
|
||||
|
||||
del _create_method
|
||||
|
||||
def setAxisItems(self, axisItems=None):
|
||||
"""
|
||||
Place axis items as given by `axisItems`. Initializes non-existing axis items.
|
||||
|
||||
============== ==========================================================================================
|
||||
**Arguments:**<
|
||||
*axisItems* Optional dictionary instructing the PlotItem to use pre-constructed items
|
||||
for its axes. The dict keys must be axis names ('left', 'bottom', 'right', 'top')
|
||||
and the values must be instances of AxisItem (or at least compatible with AxisItem).
|
||||
============== ==========================================================================================
|
||||
"""
|
||||
|
||||
|
||||
if axisItems is None:
|
||||
axisItems = {}
|
||||
|
||||
# Array containing visible axis items
|
||||
# Also containing potentially hidden axes, but they are not touched so it does not matter
|
||||
visibleAxes = ['left', 'bottom']
|
||||
visibleAxes.append(axisItems.keys()) # Note that it does not matter that this adds
|
||||
# some values to visibleAxes a second time
|
||||
|
||||
for k, pos in (('top', (1,1)), ('bottom', (3,1)), ('left', (2,0)), ('right', (2,2))):
|
||||
if k in self.axes:
|
||||
if k not in axisItems:
|
||||
continue # Nothing to do here
|
||||
|
||||
# Remove old axis
|
||||
oldAxis = self.axes[k]['item']
|
||||
self.layout.removeItem(oldAxis)
|
||||
oldAxis.scene().removeItem(oldAxis)
|
||||
oldAxis.unlinkFromView()
|
||||
|
||||
# Create new axis
|
||||
if k in axisItems:
|
||||
axis = axisItems[k]
|
||||
if axis.scene() is not None:
|
||||
if axis != self.axes[k]["item"]:
|
||||
raise RuntimeError("Can't add an axis to multiple plots.")
|
||||
else:
|
||||
axis = AxisItem(orientation=k, parent=self)
|
||||
|
||||
# Set up new axis
|
||||
axis.linkToView(self.vb)
|
||||
self.axes[k] = {'item': axis, 'pos': pos}
|
||||
self.layout.addItem(axis, *pos)
|
||||
axis.setZValue(-1000)
|
||||
axis.setFlag(axis.ItemNegativeZStacksBehindParent)
|
||||
|
||||
axisVisible = k in visibleAxes
|
||||
self.showAxis(k, axisVisible)
|
||||
|
||||
def setLogMode(self, x=None, y=None):
|
||||
"""
|
||||
|
@ -24,6 +24,7 @@ class PlotWidget(GraphicsView):
|
||||
:func:`addItem <pyqtgraph.PlotItem.addItem>`,
|
||||
:func:`removeItem <pyqtgraph.PlotItem.removeItem>`,
|
||||
:func:`clear <pyqtgraph.PlotItem.clear>`,
|
||||
:func:`setAxisItems <pyqtgraph.PlotItem.setAxisItems>`,
|
||||
:func:`setXRange <pyqtgraph.ViewBox.setXRange>`,
|
||||
:func:`setYRange <pyqtgraph.ViewBox.setYRange>`,
|
||||
:func:`setRange <pyqtgraph.ViewBox.setRange>`,
|
||||
@ -55,7 +56,7 @@ class PlotWidget(GraphicsView):
|
||||
self.setCentralItem(self.plotItem)
|
||||
## Explicitly wrap methods from plotItem
|
||||
## NOTE: If you change this list, update the documentation above as well.
|
||||
for m in ['addItem', 'removeItem', 'autoRange', 'clear', 'setXRange',
|
||||
for m in ['addItem', 'removeItem', 'autoRange', 'clear', 'setAxisItems', 'setXRange',
|
||||
'setYRange', 'setRange', 'setAspectLocked', 'setMouseEnabled',
|
||||
'setXLink', 'setYLink', 'enableAutoRange', 'disableAutoRange',
|
||||
'setLimits', 'register', 'unregister', 'viewRect']:
|
||||
@ -96,4 +97,4 @@ class PlotWidget(GraphicsView):
|
||||
return self.plotItem
|
||||
|
||||
|
||||
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user