__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 QtChart
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 `QtChart.QAbstractSeries` series: The plotted series
        :param `QtChart.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 = QtChart.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': QtChart.QScatterSeries.MarkerShapeCircle,
        'Rectangle': QtChart.QScatterSeries.MarkerShapeRectangle
    }
    LINE = 'line'
    SCATTER = 'scatter'
    BAR = 'bar'
    SUPPORTED_TYPES = {
        QtChart.QScatterSeries: SCATTER,
        QtChart.QLineSeries: LINE,
        QtChart.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 `QtChart.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, QtChart.QScatterSeries):
            self.series_type = self.SCATTER
        elif isinstance(series, QtChart.QLineSeries):
            self.series_type = self.LINE
        elif isinstance(series, QtChart.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: QtChart.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, QtChart.QXYSeries):
            # Each QXYSeries only manages one set of data
            params.append(SeriesParams(series, layout, row))
        elif isinstance(series, QtChart.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 QtChart.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 `QtChart.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(QtChart.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(QtChart.QChartView):
    """ The View for a QChart """
    PADDING = 10
[docs]    def __init__(self, chart, width, height, layout=None):
        """
        Create an SChartView object
        :param `QtChart.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 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(QtChart.QBarSeries):
    """ A QBarSeries with additional functionality for histograms """
[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, QtChart.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")  
[docs]class SHistogramBarSet(QtChart.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(QtChart.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 QtChart 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="",
            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 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, log in zip((self.BOTTOM, self.LEFT), (xtitle, ytitle),
                                    (xlog, ylog)):
            if log:
                atype = self.LOG
            else:
                atype = 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
        """
        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, QtChart.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 `QtChart.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, QtChart.QScatterSeries):
            series.setMarkerSize(size)
        elif isinstance(series, QtChart.QLineSeries):
            pen = series.pen()
            pen.setWidth(size)
            series.setPen(pen)
        elif isinstance(series, QtChart.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, QtChart.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 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 QtChart when adding a series
        :param `QtChart.QAbstractSeries` series: The series to add to the chart
        """
        if self.tracker:
            if isinstance(series, QtChart.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, QtChart.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, QtChart.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 = QtChart.QValueAxis()
            new_is_log = False
        elif self.isValueAxis(old_axis):
            new_axis = QtChart.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: QtChart.QAbstractAxis
        :return: The axis that was created
        """
        if atype == self.LOG:
            this_axis = QtChart.QLogValueAxis()
            this_axis.setBase(log)
            if log == self.BASE_10:
                this_axis.setLabelFormat('%.0e')
        elif atype == self.CATEGORY:
            this_axis = QtChart.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 = QtChart.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 `QtChart.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 `QtChart.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, QtChart.QBarSeries)):
                    # The X axis range calculated here won't be used for
                    # bar charts, so skip getting the X values
                    continue
                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 `QtChart.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'
                # QtChart 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: QtChart.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: `QtChart.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: `QtChart.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: `QtChart.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: `QtChart.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 `QtChart.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, QtChart.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 `QtChart.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, QtChart.QXYSeries):
            return [a.y() for a in series.pointsVector()]
        elif isinstance(series, QtChart.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,
                      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 `QtChart.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 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 = QtChart.QScatterSeries()
        elif series_type == self.LINE:
            series = QtChart.QLineSeries()
        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()
            self.setYAutoRange()
        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 and the default X axis title
        :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()
            self.createAxis(xside, name, atype=self.CATEGORY)
        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 `QtChart.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 QtChart 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 `QtChart.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()