def gestureEvent(self, event): pan = event.gesture(Qt.PanGesture) if pan: QChart.scroll(pan.delta().x(), pan.delta().y()) pinch = event.gesture(Qt.PinchGesture) if pinch: if pinch.changeFlags() & QPinchGesture.ScaleFactorChanged: self.zoom(pinch.scaleFactor()) return True
class IMUChartView(QChartView, BaseGraph): def __init__(self, parent=None): super().__init__(parent=parent) # Render on OpenGL self.setViewport(QOpenGLWidget()) self.xvalues = {} self.chart = QChart() self.setChart(self.chart) self.chart.legend().setVisible(True) self.chart.legend().setAlignment(Qt.AlignTop) self.ncurves = 0 self.setRenderHint(QPainter.Antialiasing) # Cursor (with chart as parent) self.cursor = QGraphicsLineItem(self.chart) self.cursor.setZValue(100.0) # self.scene().addItem(self.cursor) # Selection features # self.setRubberBand(QChartView.HorizontalRubberBand) self.selectionBand = QRubberBand(QRubberBand.Rectangle, self) self.selecting = False self.initialClick = QPoint() # Track mouse self.setMouseTracking(True) self.labelValue = QLabel(self) self.labelValue.setStyleSheet( "background-color: rgba(255,255,255,75%); color: black;") self.labelValue.setAlignment(Qt.AlignCenter) self.labelValue.setMargin(5) self.labelValue.setVisible(False) self.build_style() self.selection_start_time = None self.selection_stop_time = None self.cursor_time = None def build_style(self): self.setStyleSheet("QLabel{color:blue;}") self.chart.setTheme(QChart.ChartThemeBlueCerulean) self.setBackgroundBrush(QBrush(Qt.darkGray)) self.chart.setPlotAreaBackgroundBrush(QBrush(Qt.black)) self.chart.setPlotAreaBackgroundVisible(True) def save_as_png(self, file_path): pixmap = self.grab() child = self.findChild(QOpenGLWidget) painter = QPainter(pixmap) if child is not None: d = child.mapToGlobal(QPoint()) - self.mapToGlobal(QPoint()) painter.setCompositionMode(QPainter.CompositionMode_SourceAtop) painter.drawImage(d, child.grabFramebuffer()) painter.end() pixmap.save(file_path, 'PNG') # def closeEvent(self, event): # self.aboutToClose.emit(self) @staticmethod def decimate(xdata, ydata): # assert(len(xdata) == len(ydata)) # Decimate only if we have too much data decimate_factor = len(xdata) / 100000.0 # decimate_factor = 1.0 if decimate_factor > 1.0: decimate_factor = int(np.floor(decimate_factor)) # print('decimate factor', decimate_factor) x = np.ndarray(int(len(xdata) / decimate_factor), dtype=np.float64) y = np.ndarray(int(len(ydata) / decimate_factor), dtype=np.float64) # for i in range(len(x)): for i, _ in enumerate(x): index = i * decimate_factor # assert(index < len(xdata)) x[i] = xdata[index] y[i] = ydata[index] if x[i] < x[0]: print('timestamp error', x[i], x[0]) return x, y else: return xdata, ydata @pyqtSlot(float, float) def axis_range_changed(self, min_value, max_value): # print('axis_range_changed', min, max) for axis in self.chart.axes(): axis.applyNiceNumbers() def update_axes(self): # Get and remove all axes for axis in self.chart.axes(): self.chart.removeAxis(axis) # Create new axes # Create axis X axisx = QDateTimeAxis() axisx.setTickCount(5) axisx.setFormat("dd MMM yyyy hh:mm:ss") axisx.setTitleText("Date") self.chart.addAxis(axisx, Qt.AlignBottom) # Create axis Y axisY = QValueAxis() axisY.setTickCount(5) axisY.setLabelFormat("%.3f") axisY.setTitleText("Values") self.chart.addAxis(axisY, Qt.AlignLeft) # axisY.rangeChanged.connect(self.axis_range_changed) ymin = None ymax = None # Attach axes to series, find min-max for series in self.chart.series(): series.attachAxis(axisx) series.attachAxis(axisY) vect = series.pointsVector() # for i in range(len(vect)): for i, _ in enumerate(vect): if ymin is None: ymin = vect[i].y() ymax = vect[i].y() else: ymin = min(ymin, vect[i].y()) ymax = max(ymax, vect[i].y()) # Update range # print('min max', ymin, ymax) if ymin is not None: axisY.setRange(ymin, ymax) # Make the X,Y axis more readable # axisx.applyNiceNumbers() # axisY.applyNiceNumbers() def add_data(self, xdata, ydata, color=None, legend_text=None): curve = QLineSeries() pen = curve.pen() if color is not None: pen.setColor(color) pen.setWidthF(1.5) curve.setPen(pen) # curve.setPointsVisible(True) # curve.setUseOpenGL(True) self.total_samples = max(self.total_samples, len(xdata)) # Decimate xdecimated, ydecimated = self.decimate(xdata, ydata) # Data must be in ms since epoch # curve.append(self.series_to_polyline(xdecimated * 1000.0, ydecimated)) # self.reftime = datetime.datetime.fromtimestamp(xdecimated[0]) # if len(xdecimated) > 0: # xdecimated = xdecimated - xdecimated[0] xdecimated *= 1000 # No decimal expected points = [] for i, _ in enumerate(xdecimated): # TODO hack # curve.append(QPointF(xdecimated[i], ydecimated[i])) points.append(QPointF(xdecimated[i], ydecimated[i])) curve.replace(points) points.clear() if legend_text is not None: curve.setName(legend_text) # Needed for mouse events on series self.chart.setAcceptHoverEvents(True) self.xvalues[self.ncurves] = np.array(xdecimated) # connect signals / slots # curve.clicked.connect(self.lineseries_clicked) # curve.hovered.connect(self.lineseries_hovered) # Add series self.chart.addSeries(curve) self.ncurves += 1 self.update_axes() def update_data(self, xdata, ydata, series_id): if series_id < len(self.chart.series()): # Find start time to replace data current_series = self.chart.series()[series_id] current_points = current_series.pointsVector() # Find start and end indexes start_index = -1 try: start_index = current_points.index( QPointF(xdata[0] * 1000, ydata[0])) # Right on the value! # print("update_data: start_index found exact match.") except ValueError: # print("update_data: start_index no exact match - scanning deeper...") for i, value in enumerate(current_points): # print(str(current_points[i].x()) + " == " + str(xdata[0]*1000)) if current_points[i].x() == xdata[0] * 1000 or ( i > 0 and current_points[i - 1].x() < xdata[0] * 1000 < current_points[i].x()): start_index = i # print("update_data: start_index found approximative match.") break end_index = -1 try: end_index = current_points.index( QPointF(xdata[len(xdata) - 1] * 1000, ydata[len(ydata) - 1])) # Right on! # print("update_data: start_index found exact match.") except ValueError: # print("update_data: start_index no exact match - scanning deeper...") for i, value in enumerate(current_points): # print(str(current_points[i].x()) + " == " + str(xdata[0]*1000)) if current_points[i].x( ) == xdata[len(xdata) - 1] * 1000 or ( i > 0 and current_points[i - 1].x() < xdata[len(xdata) - 1] * 1000 < current_points[i].x()): end_index = i # print("update_data: start_index found approximative match.") break if start_index < 0 or end_index < 0: return # Decimate, if needed xdata, ydata = self.decimate(xdata, ydata) # Check if we have the same number of points for that range. If not, remove and replace! target_points = current_points[start_index:end_index] if len(target_points) != len(xdata): points = [] for i, value in enumerate(xdata): # TODO improve points.append(QPointF(value * 1000, ydata[i])) new_points = current_points[0:start_index] + points[0:len(points)-1] + \ current_points[end_index:len(current_points)-1] current_series.replace(new_points) new_points.clear() # self.xvalues[series_id] = np.array(xdata) return @classmethod def set_title(cls, title): # print('Setting title: ', title) # self.chart.setTitle(title) return @staticmethod def series_to_polyline(xdata, ydata): """Convert series data to QPolygon(F) polyline This code is derived from PythonQwt's function named `qwt.plot_curve.series_to_polyline`""" # print('series_to_polyline types:', type(xdata[0]), type(ydata[0])) size = len(xdata) polyline = QPolygonF(size) # for i in range(0, len(xdata)): # polyline[i] = QPointF(xdata[i] - xdata[0], ydata[i]) for i, data in enumerate(xdata): polyline[i] = QPointF(data - xdata[0], ydata[i]) # pointer = polyline.data() # dtype, tinfo = np.float, np.finfo # integers: = np.int, np.iinfo # pointer.setsize(2*polyline.size()*tinfo(dtype).dtype.itemsize) # memory = np.frombuffer(pointer, dtype) # memory[:(size-1)*2+1:2] = xdata # memory[1:(size-1)*2+2:2] = ydata return polyline def add_test_data(self): # 100Hz, one day accelerometer values npoints = 1000 * 60 * 24 xdata = np.linspace(0., 10., npoints) self.add_data(xdata, np.sin(xdata), color=Qt.red, legend_text='Acc. X') # self.add_data(xdata, np.cos(xdata), color=Qt.green, legend_text='Acc. Y') # self.add_data(xdata, np.cos(2 * xdata), color=Qt.blue, legend_text='Acc. Z') self.set_title( "Simple example with %d curves of %d points (OpenGL Accelerated Series)" % (self.ncurves, npoints)) def mouseMoveEvent(self, e: QMouseEvent): if self.selecting: clicked_x = max( self.chart.plotArea().x(), min( self.mapToScene(e.pos()).x(), self.chart.plotArea().x() + self.chart.plotArea().width())) clicked_y = max( self.chart.plotArea().y(), min( self.mapToScene(e.pos()).y(), self.chart.plotArea().y() + self.chart.plotArea().height())) current_pos = QPoint(clicked_x, clicked_y) # e.pos() self.selection_stop_time = self.chart.mapToValue( QPointF(clicked_x, 0)).x() self.setCursorPosition(clicked_x, True) if self.interaction_mode == GraphInteractionMode.SELECT: if current_pos.x() < self.initialClick.x(): start_x = current_pos.x() width = self.initialClick.x() - start_x else: start_x = self.initialClick.x() width = current_pos.x() - self.initialClick.x() self.selectionBand.setGeometry( QRect(start_x, self.chart.plotArea().y(), width, self.chart.plotArea().height())) if self.selection_rec: self.selection_rec.setRect( QRectF(start_x, self.chart.plotArea().y(), width, self.chart.plotArea().height())) if self.interaction_mode == GraphInteractionMode.MOVE: new_pos = current_pos - self.initialClick self.chart.scroll(-new_pos.x(), new_pos.y()) self.initialClick = current_pos def mousePressEvent(self, e: QMouseEvent): # Handling rubberbands # super().mousePressEvent(e) # Verify if click is inside plot area # if self.chart.plotArea().contains(e.pos()): self.selecting = True clicked_x = max( self.chart.plotArea().x(), min( self.mapToScene(e.pos()).x(), self.chart.plotArea().x() + self.chart.plotArea().width())) clicked_y = max( self.chart.plotArea().y(), min( self.mapToScene(e.pos()).y(), self.chart.plotArea().y() + self.chart.plotArea().height())) self.initialClick = QPoint(clicked_x, clicked_y) # e.pos() self.selection_start_time = self.chart.mapToValue(QPointF( clicked_x, 0)).x() self.setCursorPosition(clicked_x, True) if self.interaction_mode == GraphInteractionMode.SELECT: self.selectionBand.setGeometry( QRect(self.initialClick.x(), self.chart.plotArea().y(), 1, self.chart.plotArea().height())) self.selectionBand.show() if self.interaction_mode == GraphInteractionMode.MOVE: QGuiApplication.setOverrideCursor(Qt.ClosedHandCursor) self.labelValue.setVisible(False) def mouseReleaseEvent(self, e: QMouseEvent): # Assure if click is inside plot area # Handling rubberbands (with min / max x) clicked_x = max( self.chart.plotArea().x(), min( self.mapToScene(e.pos()).x(), self.chart.plotArea().x() + self.chart.plotArea().width())) if self.interaction_mode == GraphInteractionMode.SELECT: self.selectionBand.hide() if clicked_x != self.mapToScene(self.initialClick).x(): mapped_x = self.mapToScene(self.initialClick).x() if self.initialClick.x() < clicked_x: self.setSelectionArea(mapped_x, clicked_x, True) else: self.setSelectionArea(clicked_x, mapped_x, True) if self.interaction_mode == GraphInteractionMode.MOVE: QGuiApplication.restoreOverrideCursor() self.selecting = False def clearSelectionArea(self, emit_signal=False): if self.selection_rec: self.scene().removeItem(self.selection_rec) self.selection_rec = None if emit_signal: self.clearedSelectionArea.emit() def setSelectionArea(self, start_pos, end_pos, emit_signal=False): selection_brush = QBrush(QColor(153, 204, 255, 128)) selection_pen = QPen(Qt.transparent) if self.selection_rec: self.scene().removeItem(self.selection_rec) self.selection_rec = self.scene().addRect( start_pos, self.chart.plotArea().y(), end_pos - start_pos, self.chart.plotArea().height(), selection_pen, selection_brush) if emit_signal: self.selectedAreaChanged.emit( self.chart.mapToValue(QPointF(start_pos, 0)).x(), self.chart.mapToValue(QPointF(end_pos, 0)).x()) def setSelectionAreaFromTime(self, start_time, end_time, emit_signal=False): # Convert times to x values if isinstance(start_time, datetime.datetime): start_time = start_time.timestamp() * 1000 if isinstance(end_time, datetime.datetime): end_time = end_time.timestamp() * 1000 start_pos = self.chart.mapToPosition(QPointF(start_time, 0)).x() end_pos = self.chart.mapToPosition(QPointF(end_time, 0)).x() self.setSelectionArea(start_pos, end_pos) def setCursorPosition(self, pos, emit_signal=False): self.cursor_time = self.chart.mapToValue(QPointF(pos, 0)).x() # print (pos) pen = self.cursor.pen() pen.setColor(Qt.cyan) pen.setWidthF(1.0) self.cursor.setPen(pen) # On Top self.cursor.setZValue(100.0) area = self.chart.plotArea() x = pos y1 = area.y() y2 = area.y() + area.height() # self.cursor.set self.cursor.setLine(x, y1, x, y2) self.cursor.show() xmap_initial = self.chart.mapToValue(QPointF(pos, 0)).x() display = '' # '<i>' + (datetime.datetime.fromtimestamp(xmap + self.reftime.timestamp())).strftime('%d-%m-%Y %H:%M:%S') + # '</i><br />' ypos = 10 last_val = None for i in range(self.ncurves): # Find nearest point idx = (np.abs(self.xvalues[i] - xmap_initial)).argmin() ymap = self.chart.series()[i].at(idx).y() xmap = self.chart.series()[i].at(idx).x() if i == 0: display += "<i>" + (datetime.datetime.fromtimestamp(xmap_initial/1000)).strftime('%d-%m-%Y %H:%M:%S:%f') + \ "</i>" # Compute where to display label if last_val is None or ymap > last_val: last_val = ymap ypos = self.chart.mapToPosition(QPointF(xmap, ymap)).y() if display != '': display += '<br />' display += self.chart.series()[i].name( ) + ': <b>' + '%.3f' % ymap + '</b>' self.labelValue.setText(display) self.labelValue.setGeometry(pos, ypos, 100, 100) self.labelValue.adjustSize() self.labelValue.setVisible(True) if emit_signal: self.cursorMoved.emit(xmap_initial) self.update() def setCursorPositionFromTime(self, timestamp, emit_signal=False): # Find nearest point if isinstance(timestamp, datetime.datetime): timestamp = timestamp.timestamp() pos = self.get_pos_from_time(timestamp) self.setCursorPosition(pos, emit_signal) def get_pos_from_time(self, timestamp): px = timestamp idx1 = (np.abs(self.xvalues[0] - px)).argmin() x1 = self.chart.series()[0].at(idx1).x() pos1 = self.chart.mapToPosition(QPointF(x1, 0)).x() idx2 = idx1 + 1 if idx2 < len(self.chart.series()[0]): x2 = self.chart.series()[0].at(idx2).x() if x2 != x1: pos2 = self.chart.mapToPosition(QPointF(x2, 0)).x() x2 /= 1000 x1 /= 1000 pos = (((px - x1) / (x2 - x1)) * (pos2 - pos1)) + pos1 else: pos = pos1 else: pos = pos1 return pos def resizeEvent(self, e: QResizeEvent): super().resizeEvent(e) # oldSize = e.oldSize() # newSize = e.size() # Update cursor from time if self.cursor_time: self.setCursorPositionFromTime(self.cursor_time / 1000.0) # Update selection if self.selection_rec: self.setSelectionAreaFromTime(self.selection_start_time, self.selection_stop_time) def zoom_in(self): self.chart.zoomIn() self.update_axes() def zoom_out(self): self.chart.zoomOut() self.update_axes() def zoom_area(self): if self.selection_rec: zoom_rec = self.selection_rec.rect() zoom_rec.setY(0) zoom_rec.setHeight(self.chart.plotArea().height()) self.chart.zoomIn(zoom_rec) self.clearSelectionArea(True) self.update_axes() def zoom_reset(self): self.chart.zoomReset() self.update_axes() def get_displayed_start_time(self): min_x = self.chart.mapToScene(self.chart.plotArea()).boundingRect().x() xmap = self.chart.mapToValue(QPointF(min_x, 0)).x() return datetime.datetime.fromtimestamp(xmap / 1000) def get_displayed_end_time(self): max_x = self.chart.mapToScene(self.chart.plotArea()).boundingRect().x() max_x += self.chart.mapToScene( self.chart.plotArea()).boundingRect().width() xmap = self.chart.mapToValue(QPointF(max_x, 0)).x() return datetime.datetime.fromtimestamp(xmap / 1000) @property def is_zoomed(self): return self.chart.isZoomed()
class AmzHistoryChart(QWidget): """A chart that graphs the history of an AmazonListing's sales rank, price, and number of offers.""" def __init__(self, parent=None): super(AmzHistoryChart, self).__init__(parent=parent) self.dbsession = Session() self.context_menu_actions = [] self._avg_pointspan = 0 self._max_points = 100 self.source = None self.history = None layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) # Set up the chart self.chart_view = QChartView(self) self.chart_view.setRenderHint(QPainter.Antialiasing) self.chart_view.setContextMenuPolicy(Qt.CustomContextMenu) self.chart_view.customContextMenuRequested.connect(self.context_menu) self.chart = QChart() self.chart.legend().hide() self.chart.setFlags(QGraphicsItem.ItemIsFocusable | QGraphicsItem.ItemIsSelectable) self.chart.installEventFilter(self) self.chart_view.setChart(self.chart) self.layout().addWidget(self.chart_view) # Create the axes rcolor = QColor(50, 130, 220) pcolor = QColor(0, 200, 0) ocolor = QColor(255, 175, 0) self.timeAxis = QDateTimeAxis() self.timeAxis.setFormat('M/dd hh:mm') self.timeAxis.setTitleText('Date/Time') self.chart.addAxis(self.timeAxis, Qt.AlignBottom) self.timeAxis.minChanged.connect(self.on_timeaxis_min_changed) self.rankAxis = QValueAxis() self.rankAxis.setLabelFormat('%\'i') self.rankAxis.setTitleText('Sales Rank') self.rankAxis.setLinePenColor(rcolor) self.rankAxis.setLabelsColor(rcolor) self.chart.addAxis(self.rankAxis, Qt.AlignLeft) self.priceAxis = QValueAxis() self.priceAxis.setLabelFormat('$%.2f') self.priceAxis.setTitleText('Price') self.priceAxis.setLinePenColor(pcolor) self.priceAxis.setLabelsColor(pcolor) self.chart.addAxis(self.priceAxis, Qt.AlignRight) # Create the series self.rankLine = QLineSeries() self.chart.addSeries(self.rankLine) self.rankLine.attachAxis(self.timeAxis) self.rankLine.attachAxis(self.rankAxis) self.rankLine.setColor(rcolor) self.priceLine = QLineSeries() self.chart.addSeries(self.priceLine) self.priceLine.attachAxis(self.timeAxis) self.priceLine.attachAxis(self.priceAxis) self.priceLine.setColor(pcolor) self.salesPoints = QScatterSeries() self.chart.addSeries(self.salesPoints) self.salesPoints.attachAxis(self.timeAxis) self.salesPoints.attachAxis(self.rankAxis) self.salesPoints.setColor(ocolor) def add_context_action(self, action): """Add an action to the chart's context menu.""" self.context_menu_actions.append(action) def add_context_actions(self, actions): """Adds all action in an iterable.""" self.context_menu_actions.extend(actions) def remove_context_action(self, action): """Removes an action from the chart's context menu.""" self.context_menu_actions.remove(action) def context_menu(self, point): """Show a context menu on the chart.""" menu = QMenu(self) menu.addActions(self.context_menu_actions) point = self.chart_view.viewport().mapToGlobal(point) menu.popup(point) def set_source(self, source): """Set the source listing for the graph.""" self.source = source # Update the chart self.rankLine.clear() self.priceLine.clear() self.salesPoints.clear() self.history = None start_date = datetime.utcnow() - timedelta(days=5) self.load_history_from(start_date) self.reset_axes() def load_history_from(self, start_date=datetime.utcfromtimestamp(0)): """Load history data from start-present.""" if not self.source: self._avg_pointspan = 0 return # Get the earliest point already in the chart points = self.rankLine.pointsVector() if points: # The chart is drawn right-to-left, so the last point is the earliest point earliest_msecs = points[-1].x() earliest = datetime.fromtimestamp(earliest_msecs / 1000, timezone.utc) if earliest <= start_date: return else: earliest = datetime.now(timezone.utc) # Get the product history stats if we don't already have them if self.history is None: self.history = dbhelpers.ProductHistoryStats(self.dbsession, self.source.id) # Start adding points to the chart last_row = None for row in self.dbsession.query(AmzProductHistory).\ filter(AmzProductHistory.amz_listing_id == self.source.id, AmzProductHistory.timestamp > start_date.replace(tzinfo=None), AmzProductHistory.timestamp < earliest.replace(tzinfo=None)).\ order_by(AmzProductHistory.timestamp.desc()): # SqlAlchemy returns naive timestamps time = row.timestamp.replace(tzinfo=timezone.utc).timestamp() * 1000 self.rankLine.append(time, row.salesrank or 0) self.priceLine.append(time, row.price or 0) if last_row: # It's possible for salesrank to be None try: slope = (last_row.salesrank - row.salesrank) / (last_row.timestamp.timestamp() - row.timestamp.timestamp()) if slope < -0.3: self.salesPoints.append(last_row.timestamp.replace(tzinfo=timezone.utc).timestamp() * 1000, last_row.salesrank) except (TypeError, AttributeError): pass last_row = row # Calculate the average span between points spans = 0 for p1, p2 in itertools.zip_longest(itertools.islice(points, 0, None, 2), itertools.islice(points, 1, None, 2)): if p1 and p2: spans += abs(p1.x() - p2.x()) self._avg_pointspan = spans // 2 def on_timeaxis_min_changed(self, min): """Respond to a change in the time axis' minimum value.""" # toTime_t() converts to UTC automatically utc_min = datetime.fromtimestamp(min.toTime_t(), timezone.utc) self.load_history_from(start_date=utc_min - timedelta(days=1)) def reset_axes(self): """Resets the chart axes.""" r = self.rankLine.pointsVector() p = self.priceLine.pointsVector() # If there is only one data point, set the min and max to the day before and the day after if len(r) == 1: tmin = QDateTime.fromMSecsSinceEpoch(r[0].x(), Qt.LocalTime).addDays(-1) tmax = QDateTime.fromMSecsSinceEpoch(r[0].x(), Qt.LocalTime).addDays(+1) else: tmin = min(r, key=lambda pt: pt.x(), default=QPointF(QDateTime.currentDateTime().addDays(-1).toMSecsSinceEpoch(), 0)).x() tmax = max(r, key=lambda pt: pt.x(), default=QPointF(QDateTime.currentDateTime().addDays(+1).toMSecsSinceEpoch(), 0)).x() tmin = QDateTime.fromMSecsSinceEpoch(tmin, Qt.LocalTime) tmax = QDateTime.fromMSecsSinceEpoch(tmax, Qt.LocalTime) self.timeAxis.setMin(tmin) self.timeAxis.setMax(tmax) # Find the min and max values of the series min_point = lambda pts: min(pts, key=lambda pt: pt.y(), default=QPointF(0, 0)) max_point = lambda pts: max(pts, key=lambda pt: pt.y(), default=QPointF(0, 0)) rmin = min_point(r) rmax = max_point(r) pmin = min_point(p) pmax = max_point(p) # Scale the mins and maxes to 'friendly' values scalemin = lambda v, step: ((v - step / 2) // step) * step scalemax = lambda v, step: ((v + step / 2) // step + 1) * step # The the axis bounds rmin = max(scalemin(rmin.y(), 1000), 0) rmax = scalemax(rmax.y(), 1000) pmin = max(scalemin(pmin.y(), 5), 0) pmax = scalemax(pmax.y(), 5) self.rankAxis.setMin(rmin) self.rankAxis.setMax(rmax) self.priceAxis.setMin(pmin) self.priceAxis.setMax(pmax) def eventFilter(self, watched, event): """Intercept and handle mouse events.""" if event.type() == QEvent.GraphicsSceneWheel and event.orientation() == Qt.Vertical: factor = 0.95 if event.delta() < 0 else 1.05 self.chart.zoom(factor) return True if event.type() == QEvent.GraphicsSceneMouseDoubleClick: self.chart.zoomReset() self.reset_axes() return True if event.type() == QEvent.GraphicsSceneMouseMove: delta = event.pos() - event.lastPos() self.chart.scroll(-delta.x(), delta.y()) return True return False