__doc__ = """
A module for plotting with QChart.
Simple chart with a scatter plot and trendline:
self.chart = schart.SChart(
title='Simple Chart',
xtitle='Hobnobs',
ytitle='Grobniks',
layout=layout)
xvals = [3, 5, 9]
yvals = [7, 12, 14]
series_data = self.chart.addDataSeries('Production', xvals,
yvals=yvals, fit=True)
Copyright Schrodinger, LLC. All rights reserved.
"""
import bisect
import math
import random
import numpy
from scipy import stats
from schrodinger.math import mathutils
from schrodinger.Qt import QtCharts
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui.qt import messagebox
from schrodinger.ui.qt import swidgets
from schrodinger.utils import qt_utils
[docs]def compute_tick_interval(length, max_ticks=11, deltas=(1, 2, 5, 10, 10)):
"""
Computes the tick interval to span length with a maximum number of ticks
using deltas to get the preferred tick intervals (scaled to a range of 0-10).
Note: delta should start with 1 and end with two 10s.
:param length: the length of the axis, usually the highest - lowest value
:type length: float
:param max_ticks: the maximum number of ticks to use
:type max_ticks: int
:param deltas: the preferred tick intervals to use
:type deltas: indexable of float
:return: the tick interval to use
:rtype: float
"""
interval = length / max_ticks
magnitude = 10**math.floor(math.log10(interval))
return deltas[bisect.bisect_right(deltas, interval / magnitude)] * magnitude
[docs]class SeriesData(object):
"""
Holds the data for a plotted series
"""
[docs] def __init__(self, series, bar_set=None):
"""
Create a SeriesData object
:param `QtCharts.QAbstractSeries` series: The plotted series
:param `QtCharts.QBarSet` bar_set: For histograms, the plotted bar set
"""
# The plotted series
self.series = series
# The following properties are filled in later by the chart
# The series that plots the best fit line
self.fit_series = None
# The numpy fit data for the best fit line. Object type is
# scipy.stats._stats_mstats_common.LinregressResult and it has
# slope, intercept, rvalue, pvalue and stderr properties
self.fit_results = None
# Any error (str) that occured while fitting the data
self.fit_error = ""
self.bar_set = bar_set
[docs] def createTrendLine(self, name=None, fitter=None):
"""
Add or recompute a trendline to a series
:param str name: The name of the trendline series
:param callable fitter: The function to fit the data. Must have the same
API as the fitLine method. If not provided, a linear regression is
performed
:raise FitError: If an error occurs when fitting the data
"""
if fitter is None:
fitter = self.fitLine
xvals = SChart.getSeriesXVals(self.series)
yvals = SChart.getSeriesYVals(self.series)
if self.fit_series is None:
self.fit_series = QtCharts.QLineSeries()
if name is None:
name = self.series.name() + ' Fit'
self.fit_series.setName(name)
self.fit_series.setColor(self.series.color())
else:
self.fit_series.clear()
self.fit_error = ''
self.fit_results = None
try:
fit_x, fit_y, self.fit_results = fitter(xvals, yvals)
except FitError as err:
self.fit_error = str(err)
raise
for point in zip(fit_x, fit_y):
self.fit_series.append(*point)
[docs] @staticmethod
def fitLine(xvals, yvals, buffer=0):
"""
Fit a trendline to the data
:param list xvals: The x values
:param list yvals: The y values
:param float buffer: The data points returned will be at the minimum x
value minus the buffer and the maximum x value plus the buffer
:rtype: (list of float, list of float,
scipy.stats._stats_mstats_common.LinregressResult`)
:return: The x values of the data points the form the line, the
corresponding y values, and the scipy line fit resuls object
"""
if len(xvals) < 2:
raise FitError('Not enough points to fit')
else:
results = stats.linregress(xvals, yvals)
xpts = [min(xvals) - buffer, max(xvals) + buffer]
ypts = [results.slope * x + results.intercept for x in xpts]
return xpts, ypts, results
[docs]class FitError(Exception):
""" Raised for an error in fitting a trendline """
[docs]class BarSeriesSizeSpinBox(swidgets.SLabeledDoubleSpinBox):
"""
A spinbox that can have its value updated without emitting a signal
A group of these spinboxes are all connected together. When the value of one
spinbox changes, all of the others update their values. Thus, we need to
catch the signal when the first spinbox value is changed, but need a way to
update all the others without also triggering their valueChanged signals or
we end up in an infinite loop.
"""
[docs] def nonEmittingUpdate(self, value):
"""
Change the value of the spinbox without emitting a signal
:param flot value: The new value of the spinbox
"""
with qt_utils.suppress_signals(self):
self.setValue(value)
[docs]class SeriesParams(object):
""" A set of widgets that control the visual plotting of a QChart series """
SHAPES = {
'Circle': QtCharts.QScatterSeries.MarkerShapeCircle,
'Rectangle': QtCharts.QScatterSeries.MarkerShapeRectangle
}
LINE = 'line'
SCATTER = 'scatter'
BAR = 'bar'
SUPPORTED_TYPES = {
QtCharts.QScatterSeries: SCATTER,
QtCharts.QLineSeries: LINE,
QtCharts.QBarSeries: BAR
}
[docs] def __init__(self, series, layout, row, name_only=False):
"""
Create a SeriesParams object
Note that there is no overall enclosing frame or layout for this set of
widgets because they are placed individually in cells of a grid layout.
:param `QtCharts.QAbstractSeries` series: The plotted series. Currently
this class is only implemented with Line and Scatter series in mind.
:param `QtWidgets.QGridLayout` layout: The layout to place these widgets
in
:param int row: The row of the grid layout for these widgets
:param bool name_only: Show only the edit for the name of the series
"""
self.series = series
self.name_only = name_only
self.series_type = None
if isinstance(series, QtCharts.QScatterSeries):
self.series_type = self.SCATTER
elif isinstance(series, QtCharts.QLineSeries):
self.series_type = self.LINE
elif isinstance(series, QtCharts.QBarSeries):
self.series_type = self.BAR
self.name_edit = swidgets.SLineEdit(self.getName())
# Make sure the full title fits in the edit
metrics = QtGui.QFontMetrics(self.name_edit.font())
width = metrics.boundingRect(self.name_edit.text()).width()
# Fudge factor of 50 helps the edit itself not clip the text
self.name_edit.setMinimumWidth(width + 50)
# Make sure the title is scrolled to the beginning just in case it's
# clipped
self.name_edit.home(False)
layout.addWidget(self.name_edit, row, 0)
if self.name_only or self.series_type is None:
return
# Line/marker color
self.color_widget = swidgets.ColorWidget(None, 'Color:', stretch=False)
scol = self.getColor()
rgb = (scol.red(), scol.green(), scol.blue())
self.color_widget.setButtonColor(rgb)
layout.addWidget(self.color_widget, row, 1)
# Line width or marker size
size = self.getSize()
if size:
if self.series_type == self.BAR:
boxclass = BarSeriesSizeSpinBox
minimum = 0.01
stepsize = 0.05
else:
boxclass = swidgets.SLabeledSpinBox
minimum = 1
stepsize = 1
self.marker_sb = boxclass('Size:',
value=size,
minimum=minimum,
maximum=1000,
stretch=False,
stepsize=stepsize)
layout.addWidget(self.marker_sb.label, row, 2)
layout.addWidget(self.marker_sb, row, 3)
# Marker shape
if self.series_type == self.SCATTER:
self.shape_combo = swidgets.SLabeledComboBox('Shape:',
itemdict=self.SHAPES,
stretch=False)
self.shape_combo.setCurrentData(series.markerShape())
layout.addWidget(self.shape_combo.label, row, 4)
layout.addWidget(self.shape_combo, row, 5)
# Whether the series is visible or not
self.visible_cb = swidgets.SCheckBox('Visible',
checked=series.isVisible())
layout.addWidget(self.visible_cb, row, 6)
[docs] @staticmethod
def getParamRows(series, layout, row):
"""
Get a SeriesParam object for each set of plot items managed by this
series. For an QXYSeries, there will be one SeriesParam. For a
QBarSeries, there will be one SeriesParam for each QBarSet.
:type series: QtCharts.QAbstractSeries
:param series: The series to create parameters for
:type layout: swidgets.SGridBoxLayout
:param layout: The layout to place the SeriesParam widgets in
:param int row: The row of the grid layout to place the widgets in
:rtype: list
:return: Each item of the list
"""
params = []
if isinstance(series, QtCharts.QXYSeries):
# Each QXYSeries only manages one set of data
params.append(SeriesParams(series, layout, row))
elif isinstance(series, QtCharts.QBarSeries):
# A QBarSeries may manage multiple sets of data, one for each set of
# bars
if not series.barSets():
# Empty series
params.append(SeriesParams(series, layout, row, name_only=True))
else:
bparams = []
for index, barset in enumerate(series.barSets()):
bparams.append(
BarSeriesParams(barset,
series,
layout,
row,
subrow=bool(index)))
row += 1
# Some properties of bar sets are actually properties of the
# parent series, so must be the same for each bar set. Connect
# all the bar sets together so that changing one changes all.
for bpi in bparams:
for bpj in bparams:
if bpi != bpj:
bpi.visible_cb.clicked.connect(
bpj.visible_cb.setChecked)
bpi.marker_sb.valueChanged[float].connect(
bpj.marker_sb.nonEmittingUpdate)
params.extend(bparams)
else:
# Unknown series type
params.append(SeriesParams(series, layout, row, name_only=True))
return params
[docs] def getSize(self):
"""
Get the current size of the series. What "size" means depends on the the
series type
:rtype: float
:return: The series size
"""
if self.series_type == self.SCATTER:
size = int(self.series.markerSize())
elif self.series_type == self.LINE:
size = int(self.series.pen().width())
else:
size = None
return size
[docs] def setSize(self):
"""
Set the size of the series. What size means depends on the series type.
"""
SChart.setSeriesSize(self.series, self.marker_sb.value())
[docs] def getName(self):
"""
Get the name of the series
:rtype: str
:return: The name of the series
"""
return self.series.name()
[docs] def setName(self):
"""
Set the name of the series based on the widget settings
"""
self.series.setName(self.name_edit.text())
[docs] def getColor(self):
"""
Get the color of the series
"""
return self.series.color()
[docs] def setColor(self):
"""
Set the color of the series
"""
self.series.setColor(self.getColorFromWidget())
[docs] def apply(self):
"""
Apply the current widget settings to the series
"""
# Name
self.setName()
# Color
self.setColor()
# Size and shape
if self.series_type == self.SCATTER:
self.series.setMarkerShape(self.shape_combo.currentData())
self.setSize()
# Visibilty
self.series.setVisible(self.visible_cb.isChecked())
[docs]class BarSeriesParams(SeriesParams):
"""
A set of widgets that control the visual plotting of a QChart bar series
"""
[docs] def __init__(self, barset, *args, subrow=False, **kwargs):
"""
Create a BarSeriesParams instance
:param QtCharts.QBarSet barset: The bar set for these parameters
:param bool subrow: False if this is for the first bar set in the
series, True if for one of the lower ones. Only the first bar set
sets the properties that must be the same for all sets in the series
"""
self.barset = barset
self.subrow = subrow
super().__init__(*args, **kwargs)
[docs] def getSize(self):
""" See paraent method for documentation """
return self.series.barWidth()
[docs] def setSize(self):
""" See paraent method for documentation """
if self.subrow:
return
super().setSize()
[docs] def getName(self):
""" See paraent method for documentation """
return self.barset.label()
[docs] def setName(self):
""" See paraent method for documentation """
name = self.name_edit.text()
self.barset.setLabel(name)
[docs] def getColor(self):
""" See paraent method for documentation """
return self.barset.color()
[docs] def setColor(self):
""" See paraent method for documentation """
self.barset.setColor(self.getColorFromWidget())
[docs]class SeriesDialog(swidgets.SDialog):
""" A dialog allowing the user to control the visual look of series """
[docs] def __init__(self,
series,
*args,
title='Series Parameters',
help_topic='QCHART_SERIES_DIALOG',
**kwargs):
"""
Create a SeriesDialog object
:param list series: The list of series to display in this dialog
See parent class for additional documentation
"""
self.series = series
super().__init__(*args, title=title, help_topic=help_topic, **kwargs)
[docs] def layOut(self):
""" Lay out the widgets """
layout = swidgets.SHBoxLayout(layout=self.mylayout)
glayout = swidgets.SGridLayout(layout=layout)
self.params = []
for series in self.series:
row = len(self.params)
self.params.extend(SeriesParams.getParamRows(series, glayout, row))
[docs] def accept(self):
""" Apply the current settings """
for param in self.params:
param.apply()
return super().accept()
[docs]class AxisParams(object):
""" A set of widgets to control QChart axis parameters """
[docs] def __init__(self, axis, series, label, layout, row):
"""
Create an AxisParams object
The widgets are in an enclosing frame.
:param `QtCharts.QValueAxis` axis: The axis to control
:param list series: The list of data series on the plot
:param str label: The name of the axis in the dialog
:param `QtWidgets.QGridBoxLayout` layout: The layout to place the
widgets into
:param int row: The row in the grid layout where these params start
"""
self.axis = axis
self.series = series
self.is_log = SChart.isLogAxis(self.axis)
label = swidgets.SLabel(label)
layout.addWidget(label, row, 0)
# Using bottom=None allows negative values
bottom_dator = swidgets.SNonNegativeRealValidator(bottom=None)
top_dator = swidgets.SNonNegativeRealValidator(bottom=None)
minval = mathutils.round_value(self.axis.min())
self.min_edit = swidgets.SLabeledEdit('Minimum:',
edit_text=minval,
validator=bottom_dator,
stretch=False)
layout.addWidget(self.min_edit.label, row, 1)
layout.addWidget(self.min_edit, row, 2)
maxval = mathutils.round_value(self.axis.max())
self.max_edit = swidgets.SLabeledEdit('Maximum:',
edit_text=maxval,
validator=top_dator,
stretch=False)
layout.addWidget(self.max_edit.label, row, 3)
layout.addWidget(self.max_edit, row, 4)
if minval is None:
# minval is None for QBarCategory axes because axis.min() returns a
# string for that class, which causes round_value to return None
self.min_edit.label.hide()
self.min_edit.hide()
self.max_edit.label.hide()
self.max_edit.hide()
self.grid_cb = swidgets.SCheckBox('Gridlines',
checked=axis.isGridLineVisible())
layout.addWidget(self.grid_cb, row, 5)
# Next line's widgets
row += 1
font_label = swidgets.SLabel("Font size:")
font_val = self.axis.labelsFont().pointSize()
self.font_sb = swidgets.SSpinBox(minimum=6, value=font_val, stepsize=1)
layout.addWidget(font_label, row, 1)
layout.addWidget(self.font_sb, row, 2)
angle_label = swidgets.SLabel("Label angle:")
angle_val = self.axis.labelsAngle()
self.angle_sb = swidgets.SSpinBox(value=angle_val,
stepsize=1,
minimum=-90,
maximum=90)
layout.addWidget(angle_label, row, 3)
layout.addWidget(self.angle_sb, row, 4)
self.log_cb = swidgets.SCheckBox('Log', checked=self.is_log)
layout.addWidget(self.log_cb, row, 5)
if SChart.isValueAxis(self.axis):
minval, maxval = SChart.getAxisDataRange(axis, self.series)
if minval <= 0:
self.log_cb.setEnabled(False)
elif not self.is_log:
# Not a supported axis type for change to log axis
self.log_cb.hide()
[docs] def apply(self):
""" Apply the current widget settings """
if self.min_edit.isVisible():
self.axis.setMin(self.min_edit.float())
self.axis.setMax(self.max_edit.float())
grids = self.grid_cb.isChecked()
self.axis.setGridLineVisible(grids)
if self.is_log:
self.axis.setMinorGridLineVisible(grids)
font_size = self.font_sb.value()
SChart.changeAxisFontSize(self.axis, labels=font_size, title=font_size)
self.axis.setLabelsAngle(self.angle_sb.value())
[docs] def logChanged(self):
"""
Check if the current state of the log checkbox is different from the
current type of axis
:rtype: bool
:return: True if the current axis is inconsistent with the state of the
log checkbox
"""
return SChart.isLogAxis(self.axis) != self.log_cb.isChecked()
[docs]class AxesDialog(swidgets.SDialog):
"""
A dialog for controlling the axes in a QtChart
:param list axes: A list of QAbstractAxes objects this dialog should control
See parent class for additional documentation
"""
logToggled = QtCore.pyqtSignal(QtCharts.QAbstractAxis)
[docs] def __init__(self,
axes,
series,
*args,
title='Axes Parameters',
help_topic='QCHART_AXES_DIALOG',
**kwargs):
""" Create an AxesDialog object """
self.axes = []
self.series = series
# Sort the axes by alignment so we always get the axes in the same order
# (X first, Y second)
for alignment in SChart.ALIGNMENTS.values():
for axis in axes:
if axis not in self.axes and axis.alignment() == alignment:
self.axes.append(axis)
# Add any axis that may not yet be attached
for axis in axes:
if axis not in self.axes:
self.axes.append(axis)
super().__init__(*args, title=title, help_topic=help_topic, **kwargs)
[docs] def layOut(self):
""" Lay out the widgets """
layout = swidgets.SHBoxLayout(layout=self.mylayout)
glayout = swidgets.SGridLayout(layout=layout)
align_to_side = {
value: key.capitalize() for key, value in SChart.ALIGNMENTS.items()
}
self.params = []
for idx, axis in enumerate(self.axes):
label = axis.titleText()
if not label:
label = f'{align_to_side[axis.alignment()]}'
self.params.append(
AxisParams(axis, self.series, label + ':', glayout, idx * 2))
[docs] def accept(self):
""" Apply the current settings """
for param in self.params:
param.apply()
if param.logChanged():
self.logToggled.emit(param.axis)
return super().accept()
[docs]class BinsDialog(swidgets.SDialog):
"""
A dialog for controlling the bins in a QtChart histogram
"""
BINS = 'Number of bins:'
EDGES = 'Define bin edges:'
[docs] def __init__(self,
series,
*args,
title='Number of Bins',
help_topic='QCHART_BINS_DIALOG',
**kwargs):
"""
Create an BinsDialog object
:param list series: The list of series to display in this dialog
See parent class for additional documentation
"""
self.bins_updated = False
for aseries in series:
if isinstance(aseries, SHistogramBarSeries):
self.series = aseries
self.original_edges = self.series.getEdges()
break
else:
self.series = None
self.original_edges = None
super().__init__(*args, title=title, help_topic=help_topic, **kwargs)
[docs] def layOut(self):
""" Lay out the widgets """
layout = self.mylayout
self.update_cb = swidgets.SCheckBox('Update interactively',
checked=True,
layout=layout,
command=self.updateToggled)
self.mode_rbg = swidgets.SRadioButtonGroup(
nocall=True, command_clicked=self.modeChanged)
# Extract the current bin edge data
if self.series and self.series.hasEdges():
edges = self.series.getEdges()
nbins = len(edges) - 1
start = edges[0]
try:
step = edges[1] - edges[0]
except IndexError:
step = abs(edges[0]) / 10
else:
nbins = start = step = 1
# Widgets to choose the number of bins
nlayout = swidgets.SHBoxLayout(layout=layout)
bins_rb = swidgets.SRadioButton(self.BINS, checked=True, layout=nlayout)
self.bins_sb = swidgets.SSpinBox(minimum=1,
maximum=10000,
value=nbins,
layout=nlayout,
command=self.updateBins,
nocall=True)
nlayout.addStretch()
# Widgets to set the values of the bin edges
ilayout = swidgets.SHBoxLayout(layout=layout)
edges_rb = swidgets.SRadioButton(self.EDGES, layout=ilayout)
self.eframe = swidgets.SFrame(layout=ilayout,
layout_type=swidgets.HORIZONTAL)
elayout = self.eframe.mylayout
# Start
start_dator = swidgets.SNonNegativeRealValidator(bottom=None)
self.start_edit = swidgets.SLabeledEdit('Start:',
edit_text=str(start),
layout=elayout,
validator=start_dator,
always_valid=True,
width=60,
stretch=False)
# Step size
step_dator = swidgets.SNonNegativeRealValidator()
self.step_edit = swidgets.SLabeledEdit('Stepsize:',
edit_text=str(step),
layout=elayout,
validator=step_dator,
always_valid=True,
width=60,
stretch=False)
# Number of bins
bins_dator = swidgets.SNonNegativeIntValidator(bottom=1)
self.edges_edit = swidgets.SLabeledEdit('Number of bins:',
edit_text=str(nbins),
layout=elayout,
validator=bins_dator,
always_valid=True,
width=60,
stretch=False)
self.start_edit.home(False)
self.step_edit.home(False)
self.edges_edit.home(False)
self.start_edit.editingFinished.connect(self.updateBins)
self.step_edit.editingFinished.connect(self.updateBins)
self.edges_edit.editingFinished.connect(self.updateBins)
self.mode_rbg.addExistingButton(bins_rb)
self.mode_rbg.addExistingButton(edges_rb)
ilayout.addStretch(1000)
self.modeChanged(update=False)
[docs] def modeChanged(self, update=True):
"""
React to changing between defining the number of bins and the edge
values
:param bool update: Whether to update the plotted bins
"""
is_bins = self.mode_rbg.checkedText() == self.BINS
self.bins_sb.setEnabled(is_bins)
self.eframe.setEnabled(not is_bins)
if update:
self.updateBins()
[docs] def updateToggled(self):
"""
React to a change in the interactivity state
"""
if self.update_cb.isChecked():
self.updateBins()
[docs] def updateBins(self, edges=None, force=False):
"""
Update the plotted bins
:param list edges: A list of the bin edges. Each item is a float
:param bool force: Whether to force the update regardless of the
interactivity state
"""
if not self.update_cb.isChecked() and not force:
return
if self.series:
if edges is None:
if self.mode_rbg.checkedText() == self.BINS:
edges = self.bins_sb.value()
else:
start = self.start_edit.float()
step = self.step_edit.float()
numbins = self.edges_edit.int()
edges = [start + step * x for x in range(0, numbins + 1)]
self.series.updateHistograms(edges)
self.bins_updated = True
[docs] def accept(self):
""" Update the bins and close the dialog """
self.updateBins(force=True)
return super().accept()
[docs] def reject(self):
""" Reset the bins to their original state and close the dialog """
if self.bins_updated and self.original_edges is not None:
self.updateBins(self.original_edges, force=True)
return super().reject()
[docs]class SChartView(QtCharts.QChartView):
""" The View for a QChart """
PADDING = 10
[docs] def __init__(self, chart, width, height, layout=None):
"""
Create an SChartView object
:param `QtCharts.QChart` chart: The chart for this view
:param int width: The recommended minimum width (pixels) of the chart
:param int height: The recommended minimum height (pixels) of the chart
:param `QtWidgets.QBoxLayout` layout: The layout to place this chart
into
"""
self.size_width = width
self.size_height = height
super().__init__(chart)
# A label in the upper right corner that gives the current mouse
# coordinates - must be updated by the chart
self.hover_label = self.scene().addText("")
self.hover_label.setPos(width - 20, self.PADDING)
# Allow chart zooming by letting the user select a region of the chart
self.setRubberBand(self.RectangleRubberBand)
if layout is not None:
layout.addWidget(self)
[docs] def sizeHint(self, *args):
"""
Overwrite the parent method to ensure a minimum height and width of the
chart. Without this QCharts open at a minimum unreadable size.
See parent method for implementation details
"""
hint = super().sizeHint(*args)
if self.size_width:
hint.setWidth(self.size_width)
if self.size_height:
hint.setHeight(self.size_height)
return hint
[docs] def updateHoverLabel(self, xval, yval, bold=False):
"""
Update the text in the hover label
Can be overwritten in subclasses to show custom data
:type float xval: The current x position of the cursor
:type float yval: The current y position of the cursor
:type bool bold: Whether the text should be bold or not
"""
if not isinstance(xval, str):
xval = f'{xval:.3f}'
if not isinstance(yval, str):
yval = f'{yval:.3f}'
self.hover_label.setPlainText(f'{xval}, {yval}')
font = self.hover_label.font()
font.setBold(bold)
self.hover_label.setFont(font)
self.readjustHoverLabel()
[docs] def clearHoverLabel(self):
"""
Clear the hover label text
"""
self.hover_label.setPlainText('')
[docs] def readjustHoverLabel(self):
"""
Make sure the hover label is in the upper right corner of the view
"""
label_width = self.hover_label.boundingRect().width()
xval = self.width() - (label_width + self.PADDING)
self.hover_label.setPos(xval, self.PADDING)
[docs] def paintEvent(self, *args, **kwargs):
"""
Overwrite the parent method to make sure the hover label stays put
See parent method for implementation details
"""
self.readjustHoverLabel()
super().paintEvent(*args, **kwargs)
[docs]class SHistogramBarSeries(QtCharts.QBarSeries):
""" A QBarSeries with additional functionality for histograms """
binsChanged = QtCore.pyqtSignal()
[docs] def __init__(self, *args, **kwargs):
""" See parent class for documentation """
super().__init__(*args, **kwargs)
# Stores the bin boundaries - there is one more edge than bins. All bar
# sets must have the same set of edges
self._edges = None
[docs] def setEdges(self, edges):
"""
Set the edges (the bin boundaries). Since the bins occur between the
edges, there will be one more edge than there is bins
:type edges: list or numpy.array
:param edges: The bin edges. The first bin appears between edges[0] and
edges[1], the last bin appears between edges[-2] and edges[-1]
"""
self._edges = edges
[docs] def getEdges(self):
"""
Get the edges (the bin boundaries). Since the bins occur between the
edges, there will be one more edge than there is bins
:rtype: list or numpy.array
:return: The bin edges. The first bin appears between edges[0] and
edges[1], the last bin appears between edges[-2] and edges[-1]
"""
return self._edges
[docs] def hasEdges(self):
"""
Check if this series has edges set yet or not
:rtype: bool
:return: Whether edges are set for this series or not
"""
return self._edges is not None and len(self._edges) > 0
[docs] def updateHistograms(self, bin_settings):
"""
Update all the histograms in this series based on the bin setting
:type bin_settings: str, int or list
:param bin_settings: This is passed to the numpy.histogram method or the
user's supplied histogram method as the bins parameter. May be a
string such as SChart.AUTO to indicate how the bins are to be
determined. May be an integer to give the total number of bins, or
may be a list of bin edge values.
"""
for barset in self.barSets():
values, edges = barset.updateHistogram(bin_settings)
self.setEdges(edges)
self.updateAxes()
[docs] def updateAxes(self):
"""
Update the plot axes based on the current histograms
"""
for axis in self.attachedAxes():
if isinstance(axis, QtCharts.QBarCategoryAxis):
# This is a category X axis, set the values for the labels at
# the center of each bin as the average of the bin edge values
axis.clear()
centers = []
edges = self.getEdges()
for index, edge in enumerate(self.getEdges()[:-1]):
centers.append(edge + (edges[index + 1] - edge) / 2)
# Determine the precision required for the rounded labels to
# remain close to the center of the bins
# With the current formula, the displayed label values will move 1%
# of the bin width after rounding in the worst case scenario
step = edges[1] - edges[0]
precision = 2 - int(numpy.floor(numpy.log10(2 * step)))
axis.setCategories(
mathutils.round_value(x, precision) for x in centers)
else:
# This is the numerical Y axis
self.chart().setAxisAutoRange(axis)
axis.setMin(0)
axis.setLabelFormat("%.2f")
self.binsChanged.emit()
[docs] def getBinIndices(self, data, include_last_edge=True):
"""
Get the zero-based bin indices of the passed data. The left limit of
each bin is closed and the right limit is open. The index is -1 or
num_bins if the value falls outside the range.
:param array_like data: The data to get indices for
:param bool include_last_edge: Whether the last right limit should be
closed
:return list: List of bin indices of each value in the passed data
"""
edges = self.getEdges()
bin_indices = numpy.digitize(data, edges) - 1
if include_last_edge:
last_edge = edges[-1]
last_bin_idx = len(edges) - 2
for idx, val in enumerate(data):
if val == last_edge:
bin_indices[idx] = last_bin_idx
return bin_indices
[docs] def numBins(self):
"""
Get the number of bins
:return int: The number of bins
"""
edges = self.getEdges()
return len(edges) - 1 if edges is not None else 0
[docs]class SHistogramBarSet(QtCharts.QBarSet):
""" A QBarSet with additional functionality for histograms """
[docs] def __init__(self, data, name, series, fitter=numpy.histogram):
"""
Create an SHistogramBarSet instance
:param list data: If fitter is not None, data is the values that the
fitter will compute the histogram from. If fitter is None, data is
the pre-computed histogram values
:param str name: The name of this histogram set
:param SHistogramBarSeries series: The series this barset is part of
:type fitter: callable
:param fitter: The function used to fit the histogram. By default it is
the numpy.histogram function. Any user-supplied function should have
the same api. Use None to indicate that the list of values in data
are the precomputed histogram values and should not be recomputed.
"""
self.fitter = fitter
if self.fitter:
# Unbinned data is the raw values the histogram will be computed
# from
self.unbinned_data = data
# Original values are caller-supplied pre-computed histogram values
self.original_values = None
else:
self.original_values = data
self.unbinned_data = None
super().__init__(name)
series.append(self)
[docs] def fixLegend(self, legend):
"""
Fix the legend marker shapes for this bar set
:param QLegend legend: The legend containing markers for this set
"""
for marker in legend.markers():
if marker.barset() == self:
# The SChart default legend sets the marker shapes based on the
# series marker shape. For QBarSets, this always results in
# white squares. Set the marker shape for this bar set back to
# the QChart default of rectangle, which then shows a square
# colored the same as the series.
marker.setShape(legend.MarkerShapeRectangle)
[docs] def updateHistogram(self, bin_settings):
"""
Update the histogram based on the current bin settings
:type bin_settings: str, int or list
:param bin_settings: This is passed to the numpy.histogram method or the
user's supplied histogram method as the bins parameter. May be a
string such as SChart.AUTO to indicate how the bins are to be
determined. May be an integer to give the total number of bins, or
may be a list of bin edge values.
:rtype: (numpy.array, numpy.array)
:return: The first array is the list of values, one for each bin of the
histogram. The second array is the bin ediges. There is one more
edge than bin.
"""
# This clears all the values from the bar set
self.remove(0, self.count())
if not self.fitter:
self.append(self.original_values)
return self.original_values, bin_settings
values, edges = self.fitter(self.unbinned_data, bins=bin_settings)
self.append(values)
return values, edges
[docs]class SChart(QtCharts.QChart):
""" A customized implementation of the QChart class """
# Series types
LINE = 'line'
SCATTER = 'scatter'
# Sides
RIGHT = 'right'
LEFT = 'left'
BOTTOM = 'bottom'
TOP = 'top'
ALIGNMENTS = {
TOP: Qt.AlignTop,
BOTTOM: Qt.AlignBottom,
LEFT: Qt.AlignLeft,
RIGHT: Qt.AlignRight
}
# Tools
ZOOM = 'zoom_in'
AXES = 'axes'
SERIES = 'series'
COPY = 'copy'
LEGEND = 'legend'
BINS = 'bins'
DEFAULT_TOOLS = [ZOOM, AXES, SERIES, COPY, LEGEND]
# Axis types
VALUE = 'value'
LOG = 'log'
CATEGORY = 'category'
BASE_10 = 10
AUTO = 'auto'
DEFAULT_AXIS_BUFFER_PCT = 10.
DEFAULT_AXIS_FONT_SIZE = 0 # Determined when the first axis is created
DEFAULT_CATEGORY_LABEL_ANGLE = -60
DEFAULT_NONCATEGORY_LABEL_ANGLE = 0
COLORS = (
Qt.blue,
Qt.red,
Qt.darkGreen,
# Black is considered "no color" by QtCharts and will be replaced by a
# theme color when the series is added. Use "almost black" instead.
QtGui.QColor(1, 1, 1),
Qt.cyan,
Qt.magenta,
Qt.darkYellow,
Qt.darkBlue,
Qt.green,
Qt.darkRed,
Qt.gray,
Qt.darkCyan,
Qt.darkMagenta,
Qt.yellow)
[docs] def __init__(
self,
master=None,
title="",
xtype=None,
ytype=None,
xlog=None,
ylog=None,
xtitle="",
ytitle="",
width=400,
height=400,
legend=RIGHT,
tracker=True,
tools=tuple(DEFAULT_TOOLS), # noqa BLDMGR-3785
colors=COLORS,
viewclass=SChartView,
layout=None):
"""
Create an SChart object
:param `QtWidgets.QWidget` master: A QWidget - required for parenting
dialogs
:param str title: The chart title
:param str xtype: The type of x axis. If None, it will be inferred.
:param str ytype: The type of y axis. If None, it will be inferred.
:param int xlog: The log base of the X axis (None for a non-log axis)
:param int ylog: The log base of the Y axis (None for a non-log axis)
:param str xtitle: The title of the X axis
:param str ytitle: The title of the Y axis
:param int width: The recommended minimum width (pixels) of the chart
:param int height: The recommended minimum height (pixels) of the chart
:type legend: str or None
:param legend: The alignment of the legend relative to the chart (one of
the class legend side constants TOP, BOTTOM, RIGHT, LEFT), or
None to not show the legend
:param bool tracker: Whether to show the label in the upper right corner
that tracks the mouse coordinates
:type tools: tuple of str
:param tools: The tools to include in the toolbar - should be a tuple of
class tools constants.
:type colors: list or None
:param colors: A list of colors to use when adding series
without specifying the color explicitly. Use None to get the
default QChart color cycle, which has 5 colors in the default theme
and cycles repeatedly through them without noting which colors
currently exist on the chart - leading often to multiple series with
the same colors in dynamic charts. With colors specified, an attempt
is made to reuse colors early in the list if no current series has
that color, and no existing color is reused - instead a random color
will be generated if all colors current exist on the chart.
:type viewclass: class or None
:param viewclass: The view *class* (not object) for this chart -
typically a subclass of SChartView
:param `QtWidgets.QBoxLayout` layout: The layout to place this chart
into
"""
super().__init__()
self.master = master
self.tracker = tracker
if colors is not None and not colors:
raise ValueError('colors must be None or not empty')
self.colors = colors
self.tools = set(tools)
self.setupToolBar(layout)
if viewclass:
self.view = viewclass(self, width, height, layout=layout)
# Makes the plots prettier, particularly line series
self.view.setRenderHint(QtGui.QPainter.Antialiasing)
else:
self.view = None
if title:
self.setTitle(title)
for side, title, atype, log in zip((self.BOTTOM, self.LEFT),
(xtitle, ytitle), (xtype, ytype),
(xlog, ylog)):
if not atype:
atype = self.LOG if log else self.VALUE
self.createAxis(side, title, log=log, atype=atype)
# Legend
chart_legend = self.legend()
# Default legend behavior is to use square markers for all series
chart_legend.setMarkerShape(chart_legend.MarkerShapeFromSeries)
if legend is None:
self.showLegend(False)
else:
chart_legend.setAlignment(self.ALIGNMENTS[legend])
chart_legend.setShowToolTips(True)
# Needed to make the hover label in the view work properly
self.setAcceptHoverEvents(True)
[docs] def getChartImage(self):
"""
Get the chart image
:rtype: `QtGui.QImage`
:return: The chart image as a QImage
"""
# Clear the hover label just in case the hoverLeaveEvent wasn't emitted
self.view.clearHoverLabel()
size = self.view.size()
my_image = QtGui.QImage(size, QtGui.QImage.Format_RGB32)
painter = QtGui.QPainter()
painter.begin(my_image)
painter.fillRect(0, 0, size.width(), size.height(), Qt.white)
self.view.render(painter)
painter.end()
return my_image
[docs] def copyToClipboard(self):
"""
Copy the chart image to the clipboard
"""
my_image = self.getChartImage()
QtWidgets.QApplication.clipboard().setImage(my_image)
[docs] def exportImage(self, file_path):
"""
Export the chart image to a file
:param str file_path: The path to export the image to
:rtype: bool
:return: True if exported, False if export failed
"""
my_image = self.getChartImage()
return my_image.save(file_path)
[docs] def openBinsDialog(self):
"""
Open the dialog that allows the user to modify the histogram bins
"""
dlg = BinsDialog(self.series(), self.view)
dlg.exec()
[docs] def getNextColor(self):
"""
Get the next color to use for plotting a series. Colors at the beginning
of the list will be reused if no series with that color currently
exists. If all available colors are currently used, a random color will
be generated.
:rtype: QtGui.QColor or None
:return: The next color to use for plotting, or None if there is no
color list defined
"""
if self.colors is None:
return None
# Gather the currently-used colors
# "used" should be a set for performance issues, but QColor objects
# cannot be hashed. The list of used colors should be small.
used = []
for series in self.series():
if isinstance(series, QtCharts.QBarSeries):
for barset in series.barSets():
used.append(barset.color())
else:
try:
used.append(series.color())
except AttributeError:
# An unknown series types may not have the color method
pass
# Find the first unused color
for color in self.colors:
if color not in used:
return QtGui.QColor(color)
# If all colors are used, generate a random color
rgb = tuple(random.randint(0, 255) for x in range(3))
return QtGui.QColor(*rgb)
[docs] @staticmethod
def setSeriesSize(series, size):
"""
Set the size for this series. This is marker size for scatter plots or
thickness for line plots.
:param `QtCharts.QAbstractSeres` series: The series to modify
:param int size: The size for the series
:raise RuntimeError: If the series is not scatter or line
"""
if isinstance(series, QtCharts.QScatterSeries):
series.setMarkerSize(size)
elif isinstance(series, QtCharts.QLineSeries):
pen = series.pen()
pen.setWidth(size)
series.setPen(pen)
elif isinstance(series, QtCharts.QBarSeries):
series.setBarWidth(size)
else:
raise RuntimeError('Cannot set size for this type of series: ',
type(series))
[docs] def openSeriesDialog(self):
"""
Open the dialog that allows user control of series parameters
"""
dlg = SeriesDialog(self.series(), self.view)
dlg.exec()
[docs] def openAxesDialog(self):
"""
Open the dialog that allows user control of axis parameters
"""
dlg = AxesDialog(self.axes(), self.series(), self.view)
dlg.logToggled.connect(self.toggleLogAxis)
dlg.exec()
dlg.logToggled.disconnect(self.toggleLogAxis)
[docs] def resetAxisLabels(self):
"""
Reset axis labels' angle and font size
"""
for axis in self.axes():
if isinstance(axis, QtCharts.QBarCategoryAxis):
axis.setLabelsAngle(self.DEFAULT_CATEGORY_LABEL_ANGLE)
else:
axis.setLabelsAngle(self.DEFAULT_NONCATEGORY_LABEL_ANGLE)
self.changeAxisFontSize(axis,
labels=self.DEFAULT_AXIS_FONT_SIZE,
title=self.DEFAULT_AXIS_FONT_SIZE)
[docs] def resetView(self):
"""
Unzoom the plot and reset the axes parameters if necessary
"""
self.zoomReset()
if self.AXES in self.tools:
self.setXAutoRange()
self.setYAutoRange()
[docs] def showLegend(self, show):
"""
Set the legend visibility
:param bool show: The legend visibility
"""
self.legend().setVisible(show)
[docs] def hoverMoveEvent(self, event):
"""
Catch the mouse moving in the chart and update the hover tracking label
with its coordinates
:param `QtGui.QHoverEvent` event: The event object
"""
if self.tracker:
point = self.mapToValue(event.pos())
self.view.updateHoverLabel(point.x(), point.y())
return super().hoverMoveEvent(event)
[docs] def hoverLeaveEvent(self, event):
"""
Hide the position tracker when the mouse leaves the chart area
:param `QtGui.QGraphicsSceneHoverEvent` event: The hover leave event
"""
self.view.clearHoverLabel()
return super().hoverLeaveEvent(event)
[docs] def addSeries(self, series):
"""
Add the series to the chart
:note: This function changes the color of black series to a theme color
- this is done automatically by QtCharts when adding a series
:param `QtCharts.QAbstractSeries` series: The series to add to the chart
"""
if self.tracker:
if isinstance(series, QtCharts.QBarSeries):
for barset in series.barSets():
barset.hovered.connect(self.barsHovered)
else:
series.hovered.connect(self.seriesHovered)
super().addSeries(series)
[docs] @staticmethod
def changeAxisFontSize(axis,
labels=DEFAULT_AXIS_FONT_SIZE,
title=DEFAULT_AXIS_FONT_SIZE):
"""
Change the font sizes for the axis
:param QAbstractAxis axis: The axis
:param int labels: The font size for labels
:param int title: The font size for the title
"""
labels_font = axis.labelsFont()
labels_font.setPointSize(labels)
axis.setLabelsFont(labels_font)
title_font = axis.titleFont()
title_font.setPointSize(title)
axis.setTitleFont(title_font)
[docs] @staticmethod
def isLogAxis(axis):
"""
Check if this axis is a log axis
:rtype: bool
:return: Whether the axis is log or not
"""
return isinstance(axis, QtCharts.QLogValueAxis)
[docs] @staticmethod
def isValueAxis(axis):
"""
Check if this axis is a value axis
:rtype: bool
:return: Whether the axis is value or not
"""
return isinstance(axis, QtCharts.QValueAxis)
[docs] def toggleLogAxis(self, old_axis, base=BASE_10):
"""
Change a linear axis to log, or vise versa
:param QAbstractAxis old_axis: The axis to change
:param int base: The log base if changing to a log axis
:rtype: QAbstractAxis
:return: The new axis
:raise RuntimeError: if old_axis is not a QLogValueAxis or QValueAxis
"""
if self.isLogAxis(old_axis):
new_axis = QtCharts.QValueAxis()
new_is_log = False
elif self.isValueAxis(old_axis):
new_axis = QtCharts.QLogValueAxis()
new_axis.setBase(base)
new_is_log = True
else:
raise RuntimeError('Can not toggle log state of a '
f'{type(old_axis)} axis.')
# Copy the text settings
new_axis.setTitleText(old_axis.titleText())
self.changeAxisFontSize(new_axis,
labels=old_axis.labelsFont().pointSize(),
title=old_axis.titleFont().pointSize())
new_axis.setLabelsAngle(old_axis.labelsAngle())
# Copy the grid settings
grids = old_axis.isGridLineVisible()
new_axis.setGridLineVisible(grids)
if new_is_log:
if grids:
new_axis.setMinorTickCount(-1)
else:
new_axis.setMinorTickCount(0)
new_axis.setLabelFormat('%.0e')
# Add the new axis to the chart and remove the old one
self.addAxis(new_axis, old_axis.alignment())
for series in self.series():
if old_axis in series.attachedAxes():
series.detachAxis(old_axis)
series.attachAxis(new_axis)
if old_axis in self.axes():
self.removeAxis(old_axis)
# Set reasonable axis range
self.setAxisAutoRange(new_axis)
return new_axis
[docs] def createAxis(self, side, title, log=BASE_10, unique=True, atype=VALUE):
"""
Create an axis on one side of the chart. Note that if an axis in that
direction already exists, there will be multiple axes in that direction.
:param str side: A side class constant giving the side of the chart the
axis will be on
:param str title: The label for the axis
:type log: int
:param log: The log base of the axis if a log axis is requested
:param bool unique: If True, remove any existing axis on this side. Any
series attached to a removed axis must be manually attached to a new
axis.
:param str atype: The type of axis. Should be a class axis type constant
:rtype: QtCharts.QAbstractAxis
:return: The axis that was created
"""
if atype == self.LOG:
this_axis = QtCharts.QLogValueAxis()
this_axis.setBase(log)
if log == self.BASE_10:
this_axis.setLabelFormat('%.0e')
elif atype == self.CATEGORY:
this_axis = QtCharts.QBarCategoryAxis()
# Histogram labels don't show if they are wider than the category.
# Setting them at an angle helps with that.
this_axis.setLabelsAngle(self.DEFAULT_CATEGORY_LABEL_ANGLE)
else:
this_axis = QtCharts.QValueAxis()
this_axis.setTitleText(title)
alignment = self.ALIGNMENTS[side]
if unique:
for axis in self.axes():
if axis.alignment() == alignment:
self.removeAxis(axis)
self.addAxis(this_axis, self.ALIGNMENTS[side])
# Store the default axis font size the first time an axis is created.
# Used for restoring default font size when resetting the chart.
if not self.DEFAULT_AXIS_FONT_SIZE:
self.DEFAULT_AXIS_FONT_SIZE = this_axis.labelsFont().pointSize()
return this_axis
[docs] def setXMinMax(self, minimum=None, maximum=None, side=BOTTOM):
"""
Set the min and max values of the x axis
:param float minimum: The minimum value of the axis
:param float maximum: The maximum value of the axis
:param str side: The side of the plot the desired axis is attached to.
Must be a class side constant.
"""
axis = self.getSideAxis(side)
self.setAxisMinMax(axis, minimum, maximum)
[docs] def setYMinMax(self, minimum=None, maximum=None, side=LEFT):
"""
Set the min and max values of the y axis
:param float minimum: The minimum value of the axis
:param float maximum: The maximum value of the axis
:param str side: The side of the plot the desired axis is attached to
Must be a class side constant.
"""
axis = self.getSideAxis(side)
self.setAxisMinMax(axis, minimum, maximum)
[docs] def setAxisMinMax(self, axis, minimum, maximum):
"""
Set the min and max values of an axis
:param `QtCharts.QAbstractAxis` axis: The axis to set.
:param float minimum: The minimum value of the axis
:param float maximum: The maximum value of the axis
"""
if minimum is not None:
axis.setMin(minimum)
if maximum is not None:
axis.setMax(maximum)
[docs] def setXAutoRange(self,
buffer=0,
buffer_pct=DEFAULT_AXIS_BUFFER_PCT,
side=BOTTOM):
"""
Automatically set the x axis range based on the min and max values of
the plotted data
:param float buffer: Additional absolute amount to increase the axis
range beyond the min and max plotted values (0 will truncate the
axis at exactly the min and max values)
:param float buffer_pct: Additional percent amount to increase the axis
range beyond the min and max plotted values. The percent is computed
of the entire range and then applied to both the low and high end of
the axis.
:param str side: The side of the plot the desired axis is attached to
Must be a class side constant.
"""
axis = self.getSideAxis(side)
self.setAxisAutoRange(axis, buffer=buffer, buffer_pct=buffer_pct)
[docs] def setYAutoRange(self,
buffer=0,
buffer_pct=DEFAULT_AXIS_BUFFER_PCT,
side=LEFT):
"""
Automatically set the y axis range based on the min and max values of
the plotted data
:param float buffer: Additional absolute amount to increase the axis
range beyond the min and max plotted values (0 will truncate the
axis at exactly the min and max values)
:param float buffer_pct: Additional percent amount to increase the axis
range beyond the min and max plotted values. The percent is computed
of the entire range and then applied to both the low and high end of
the axis.
:param str side: The side of the plot the desired axis is attached to
Must be a class side constant.
"""
axis = self.getSideAxis(side)
self.setAxisAutoRange(axis, buffer=buffer, buffer_pct=buffer_pct)
[docs] @staticmethod
def getAxisDataRange(axis, series):
"""
Find the range of data attached to the given axis
:param `QtCharts.QAbstractAxis` axis: The axis
:param list series: The current list of data series
:rtype: float, float
:return: The min and max data values. numpy.inf is returned if there is
no associated data
"""
if axis.alignment() in (Qt.AlignTop, Qt.AlignBottom):
getter = SChart.getSeriesXVals
else:
getter = SChart.getSeriesYVals
# Find the min and max values of all the data
minval = numpy.inf
maxval = -numpy.inf
for series in series:
if axis in series.attachedAxes():
if (getter == SChart.getSeriesXVals and
isinstance(series, QtCharts.QBarSeries)):
# The X axis range calculated here won't be used for
# bar charts, so skip getting the X values
return -numpy.inf, numpy.inf
vals = getter(series)
if vals:
minval = min(min(vals), minval)
maxval = max(max(vals), maxval)
return minval, maxval
[docs] def setAxisAutoRange(self,
axis,
buffer=0,
buffer_pct=DEFAULT_AXIS_BUFFER_PCT):
"""
Automatically set the y axis range based on the min and max values of
the plotted data
:param `QtCharts.QAbstractAxis` axis: The axis to set.
:param float buffer: Additional absolute amount to increase the axis
range beyond the min and max plotted values (0 will truncate the
axis at exactly the min and max values)
:param float buffer_pct: Additional percent amount to increase the axis
range beyond the min and max plotted values. The percent is computed
of the entire range and then applied to both the low and high end of
the axis.
"""
is_log = self.isLogAxis(axis)
minval, maxval = self.getAxisDataRange(axis, self.series())
# Apply any buffer
data_range = abs(maxval - minval)
if data_range == numpy.inf:
data_range = 0
minval = None
maxval = None
else:
if is_log:
if minval == maxval:
# Ensure min and max are not equal without changing their
# sign. We don't have to worry about minval=maxval=0 because
# 0 is going to be invalid for a log axis anyway.
minval = 0.95 * minval
maxval = 1.05 * maxval
msg = 'Invalid data range for a log axis'
# QtCharts log axes only label the ticks at integer log values,
# so set the axis min/max to such values so that at least some
# labels appear on the chart.
try:
minval = 10**(math.floor(math.log(minval, 10)))
except ValueError:
# If the data range is invalid for a log axis, just leave
# the axis at its current values.
self.warning(msg)
return
try:
maxval = 10**(math.ceil(math.log(maxval, 10)))
except ValueError:
self.warning(msg)
return
else:
total_buffer = buffer + buffer_pct * data_range / 100
minval_with_buffer = minval - total_buffer
maxval_with_buffer = maxval + total_buffer
# Ensure we don't have negative axis values for all-positive
# data, and vice versa
if minval >= 0 and minval_with_buffer < 0:
minval = 0
else:
minval = minval_with_buffer
if maxval <= 0 and maxval_with_buffer > 0:
maxval = 0
else:
maxval = maxval_with_buffer
# Ensure that min and max are not equal
if minval == maxval:
minval -= 1
maxval += 1
# Apply to the axis
self.setAxisMinMax(axis, minimum=minval, maximum=maxval)
[docs] def warning(self, msg, **kwargs):
"""
Pop up a warning dialog with the given message
:param str msg: The message to display
"""
messagebox.show_warning(self.view, msg, **kwargs)
[docs] def getSideAxis(self, side):
"""
Get the axis associated with the given side of the chart. If more than
one axis is associated with that side, the first one is returned.
:param str side: The side of the plot the desired axis is attached to.
Must be a class side constant.
:rtype: QtCharts.QAbstractAxis
:return: The axis attached to this side of the chart
:raise KeyError: if side is not a valid constant
:raise ValueError: If no such axis exists
"""
try:
alignment = self.ALIGNMENTS[side]
except KeyError:
raise KeyError(f'{side} is not a valid class side constant')
for axis in self.axes():
if axis.alignment() == alignment:
return axis
raise ValueError(f'No axis exists for side {side}')
@property
def bottom_axis(self):
"""
Get the axis on the bottom. Will return the first one if more than one
exist
:raise ValueError: If no such axis exists
:rtype: `QtCharts.QAbstractAxis`
:return: The axis attached to the bottom side of the chart
"""
return self.getSideAxis(self.BOTTOM)
@property
def left_axis(self):
"""
Get the axis on the left. Will return the first one if more than one
exist
:raise ValueError: If no such axis exists
:rtype: `QtCharts.QAbstractAxis`
:return: The axis attached to the left side of the chart
"""
return self.getSideAxis(self.LEFT)
@property
def top_axis(self):
"""
Get the axis on the top. Will return the first one if more than one
exist
:raise ValueError: If no such axis exists
:rtype: `QtCharts.QAbstractAxis`
:return: The axis attached on the top side of the chart
"""
return self.getSideAxis(self.TOP)
@property
def right_axis(self):
"""
Get the axis on the right. Will return the first one if more than one
exist
:raise ValueError: If no such axis exists
:rtype: `QtCharts.QAbstractAxis`
:return: The axis attached on the right side of the chart
"""
return self.getSideAxis(self.RIGHT)
[docs] @staticmethod
def getSeriesXVals(series):
"""
Get the x values for a series
:param `QtCharts.QXYSeries` series: The series to get data from
:rtype: list
:return: The x values of all points in the series
:raise ValueError: if series is not a supported type
"""
if isinstance(series, QtCharts.QXYSeries):
return [a.x() for a in series.pointsVector()]
raise ValueError('series is not a supported series type')
[docs] @staticmethod
def getSeriesYVals(series):
"""
Get the y values for a series. Note that for bar series, ALL the y
values of all the bar sets are returned as a single list.
:param `QtCharts.QAbstractSeries` series: The series to get data from.
Only QXYSeries and QBarSeries are currently supported
:rtype: list
:return: The y values of all points in the series
:raise ValueError: if series is not a supported type
"""
if isinstance(series, QtCharts.QXYSeries):
return [a.y() for a in series.pointsVector()]
elif isinstance(series, QtCharts.QBarSeries):
values = []
for barset in series.barSets():
values.extend([barset.at(x) for x in range(barset.count())])
return values
else:
raise ValueError('series is not a supported series type')
[docs] def addDataSeries(self,
name,
xvals,
yvals=None,
series_type=None,
color=None,
shape=None,
size=None,
line_style=Qt.SolidLine,
legend=True,
autorange=True,
fit=False,
fitter=None,
xside=BOTTOM,
yside=LEFT):
"""
Add a new data series to the chart
:param str name: The name of the series - this will be the name of the
series shown in the legend
:type xvals: list of float or list of tuple
:param xvals: Either a list of x data, or a list of (x, y) tuples
:param list yvals: List of y data if xvals is not (x, y) tuples
:param str series_type: The series type - must be a class series type
constant
:type color: `QtGui.QColor` or Qt.GlobalColor constant or None
:param color: The color for this series. If None, a color from the
current color list will be used.
:param `QtCharts.QScatterSeries.MarkerShape` shape: For scatter series,
the shape of the markers
:param int size: For scatter series, the size of the markers.
For line series, the thickness of the line.
:param int line_style: Type of the line. Values from Qt::PenStyle enum
:param bool legend: Whether the series should show in the legend
:param bool autorange: Whether to auto-set the axes ranges after
adding this series
:param bool fit: Whether to add a trendline to the series
:type fitter: callable
:param fitter: A function to call to fit the trendline. Must follow the
API of the fitLine method
:param str xside: The X axis side to attach the series to. Should
be either BOTTOM or TOP
:param str yside: The Y axis side to attach the series to. Should
be either LEFT or RIGHT
:rtype: SeriesData
:return: The newly created series will be in the SeriesData.series
property. If a trendline is added, data about the trendline will
also be included.
"""
# Create the series object
if series_type is None or series_type == self.SCATTER:
series = QtCharts.QScatterSeries()
elif series_type == self.LINE:
series = QtCharts.QLineSeries()
# Update line style
pen = series.pen()
pen.setStyle(line_style)
series.setPen(pen)
else:
raise ValueError('series_type must be a class series constant')
# Add the data to the series
series.setName(name)
if yvals is not None:
points = zip(xvals, yvals)
else:
points = xvals
for point in points:
series.append(*point)
# Set custom series properties
if color is not None:
series.setColor(color)
else:
series.setColor(self.getNextColor())
if shape is not None:
series.setMarkerShape(shape)
if size is not None:
self.setSeriesSize(series, size)
# Add the series to the chart and prepare the return object
self.addAndAttachSeries(series, xside=xside, yside=yside)
data = SeriesData(series)
if not legend:
self.showSeriesInLegend(series, False)
# Add a trendline if requested
if fit:
self.addTrendLine(data, fitter=fitter)
# Set axis ranges
if autorange:
self.setXAutoRange(side=xside)
self.setYAutoRange(side=yside)
return data
[docs] def addHistogram(self,
name,
values,
bin_settings=AUTO,
bar_series=None,
color=None,
size=1,
legend=True,
fitter=numpy.histogram,
xside=BOTTOM,
yside=LEFT,
barset_class=SHistogramBarSet,
barseries_class=SHistogramBarSeries):
"""
Add a new histogram to the chart.
Note that for QCharts, a "bar set" is a set of bars that make up a
histogram (or other data related by a bunch of bars). A "bar series" is
a collection of one or more bar sets. For instance, if you wanted to
display two different histograms on the same chart in the same location.
A bar set is much more analogous to a normal QXYSeries than a bar series
is.
:param str name: The name of the histogram - this will be the name of
the series shown in the legend
:param list values: The set of values from which the histogram will be
computed. See also the fitter parameter.
:type bin_settings: str, int or list
:param bin_settings: This is passed to the numpy.histogram method or the
user's supplied histogram method as the bins parameter. May be a
string such as SChart.AUTO to indicate how the bins are to be
determined. May be an integer to give the total number of bins, or
may be a list of bin edge values. See also the bar_settings
parameter.
:param SHistogramBarSeries bar_series: An existing series that this
histogram should be associated with. If used, the value of
bin_settings is ignored if the bar_series has existing edges.
:type color: `QtGui.QColor` or Qt.GlobalColor constant or None
:param color: The color for this histogram. If None, a color from the
current color list will be used.
:param int size: The width of the histogram bars. 1 indicates bars that
should touch but not overlap. Values < 1 will leave a proportionate
amount of space between the bars.
:param bool legend: Whether the series should show in the legend
:type fitter: callable
:param fitter: A function to call to compute the histogram using the
data in values. Must follow the API of the numpy.histgram function.
If None, then the data in values is considered the pre-computed
histogram and will be used directly without modification. In this
case, the bin_settings should be the list of bin edges.
:param str xside: The X axis side to attach the series to. Should
be either BOTTOM or TOP
:param str yside: The Y axis side to attach the series to. Should
be either LEFT or RIGHT
:param class barset_class: The class to use to create the bar set for
this histogram
:param class barseries_class: The class to use to create the bar series
this histogram should be attached to.
:rtype: SeriesData
:return: The bar series will be in the SeriesData.series
property. The new bar set will be in the SeriesData.bar_set
property.
"""
existing_series = bool(bar_series)
if existing_series:
# Bar series can only have one set of edges for all bar sets, so use
# any existing edge values
if bar_series.hasEdges():
bin_settings = bar_series.getEdges()
# The with of all bar sets in a ber series must be the same
size = bar_series.barWidth()
else:
bar_series = barseries_class()
barset = barset_class(values, name, bar_series, fitter=fitter)
bar_series_data = SeriesData(bar_series, bar_set=barset)
hist, edges = barset.updateHistogram(bin_settings)
# Color is set on the bar set
if color is not None:
barset.setColor(color)
else:
barset.setColor(self.getNextColor())
# Size is set on the bar series
if size is not None:
self.setSeriesSize(bar_series, size)
if existing_series:
if self.tracker:
barset.hovered.connect(self.barsHovered)
else:
self.addAndAttachSeries(bar_series, xside=xside, yside=yside)
bar_series.setEdges(edges)
# Do some cleanup that must occur after the series is attached to axes
bar_series.updateAxes()
barset.fixLegend(self.legend())
return bar_series_data
[docs] def seriesHovered(self, point, is_hovered):
"""
Callback for when the mouse is over a point in the series
:param `QtCore.QPointF` point: The point the mouse is over
:param is_hovered: True if the mouse is over the point, False if the
mouse left the point.
"""
if is_hovered:
self.view.updateHoverLabel(point.x(), point.y(), bold=True)
[docs] def barsHovered(self, is_hovered, index):
"""
Callback for when the mouse is over a bar in a bar set
:param is_hovered: True if the mouse is over the bar, False if the
mouse left the bar.
:param int index: The index of the bar the mouse is over. It may belong
to one of many different bar sets
"""
if is_hovered:
barset = self.sender()
series = barset.parent()
try:
edges = series.getEdges()
except AttributeError:
xval = index
else:
lowval = edges[index]
highval = edges[index + 1]
xval = f'{lowval:.3f}-{highval:.3f}'
yval = barset.at(index)
self.view.updateHoverLabel(xval, yval, bold=True)
[docs] def addAndAttachSeries(self, series, xside=BOTTOM, yside=LEFT):
"""
Add the series to the chart and attach it to the x and y axes
:param `QtCharts.QAbstractSeries` series: The series to add to the chart
:param str xside: The X axis side to attach the series to. Should
be either BOTTOM or TOP
:param str yside: The Y axis side to attach the series to. Should
be either LEFT or RIGHT
"""
self.addSeries(series)
for side in (xside, yside):
axis = self.getSideAxis(side)
series.attachAxis(axis)
[docs] def addTrendLine(self, data, name=None, fitter=None):
"""
Add a trendline to a series
:param `SeriesData` data: The SeriesData that contains the series to add
the trendline to
:param str name: The name of the trendline series
:param callable fitter: The function to fit the data. Must have the same
API as the fitLine method.
"""
try:
data.createTrendLine(name=name, fitter=fitter)
except FitError:
return
color = data.fit_series.color()
self.addAndAttachSeries(data.fit_series)
# Note - reset the color after the series is added because QtCharts will
# change black to a theme color when adding the series (it apparently
# assumes black means "no color has been set")
data.fit_series.setColor(color)
self.showSeriesInLegend(data.fit_series, show=False)
[docs] def showSeriesInLegend(self, series, show=True):
"""
Set the visibility of the given series in the legend
:param `QtCharts.QAbstractSeries` series: The series to set the
visibility for
:param bool show: Whether to show the series or not
"""
self.legend().markers(series)[0].setVisible(show)
[docs] def reset(self):
"""
Remove all the series from the chart, resets the view, and resets
axis labels
"""
self.removeAllSeries()
self.resetView()
self.resetAxisLabels()