Source code for schrodinger.ui.qt.schart

__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 getColorFromWidget(self): """ Get the color from the color widget :rtype: QtGui.QColor :return: The current color of the color widget """ return QtGui.QColor(*self.color_widget.rgb_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 setupToolBar(self, layout): """ Create the toolbar :param `QtWidgets.QBoxLayout` layout: The layout to place this toolbar into """ self.toolbar = swidgets.SFrame(layout=layout, layout_type=swidgets.HORIZONTAL) tlayout = self.toolbar.mylayout if self.ZOOM in self.tools: self.zoom_in_btn = swidgets.SPushButton('Zoom In', command=self.zoomIn, layout=tlayout) self.zoom_out_btn = swidgets.SPushButton('Zoom Out', command=self.zoomOut, layout=tlayout) if self.AXES in self.tools: self.axes_tool_btn = swidgets.SPushButton( 'Axes...', command=self.openAxesDialog, layout=tlayout) if self.ZOOM in self.tools or self.AXES in self.tools: self.view_reset_btn = swidgets.SPushButton('Reset View', command=self.resetView, layout=tlayout) if self.SERIES in self.tools: self.series_btn = swidgets.SPushButton( 'Series...', command=self.openSeriesDialog, layout=tlayout) if self.BINS in self.tools: self.bins_btn = swidgets.SPushButton('Bins...', command=self.openBinsDialog, layout=tlayout) if self.COPY in self.tools: self.tools_btn = swidgets.SPushButton('Copy', command=self.copyToClipboard, layout=tlayout) tlayout.addStretch() if self.LEGEND in self.tools: self.legend_cb = swidgets.SCheckBox('Legend', checked=True, command=self.showLegend, layout=tlayout)
[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()