diff --git a/python/lognplot/chart/chart.py b/python/lognplot/chart/chart.py index 59e499f..e7cb4e7 100644 --- a/python/lognplot/chart/chart.py +++ b/python/lognplot/chart/chart.py @@ -18,6 +18,7 @@ def __init__(self, db): self.x_axis = Axis() self.y_axis = Axis() self.curves = [] + self.activeCurve = None self.cursor = None self.db = db @@ -31,9 +32,15 @@ def add_curve(self, name, color): if not self.has_curve(name): curve = Curve(self.db, name, color) self.curves.append(curve) + self.change_active_curve(curve) def clear_curves(self): self.curves.clear() + self.y_axis = Axis() + + def change_active_curve(self, curve): + self.activeCurve = curve + self.y_axis = self.activeCurve.axis def info(self): print(f"Chart with {len(self.curves)} series") diff --git a/python/lognplot/chart/curve.py b/python/lognplot/chart/curve.py index c127475..cb8c96e 100644 --- a/python/lognplot/chart/curve.py +++ b/python/lognplot/chart/curve.py @@ -1,5 +1,5 @@ from ..tsdb.aggregation import Aggregation - +from .axis import Axis class Curve: """ A curve is a view onto a signal in the database. @@ -14,6 +14,12 @@ def __init__(self, db, name, color): self._db = db self.name = name self.color = color + # Average of the visual part of the curve + self.average = 0 + # Corresponding handle (polygon area) + self.handle = [] + # Each curve has its own vertical axis + self.axis = Axis() def __repr__(self): return "Database proxy-curve" diff --git a/python/lognplot/qt/render/base.py b/python/lognplot/qt/render/base.py index 0eb8e03..af1132a 100644 --- a/python/lognplot/qt/render/base.py +++ b/python/lognplot/qt/render/base.py @@ -35,7 +35,7 @@ def calc_y_ticks(self, axis): y_ticks = axis.get_ticks(amount_y_ticks) return y_ticks - def draw_grid(self, x_ticks, y_ticks): + def draw_grid(self, y_axis, x_ticks, y_ticks): """ Render a grid on the given x and y tick markers. """ pen = QtGui.QPen(Qt.gray) pen.setWidth(1) @@ -46,7 +46,7 @@ def draw_grid(self, x_ticks, y_ticks): self.painter.drawLine(x, self.layout.chart_top, x, self.layout.chart_bottom) for value, _ in y_ticks: - y = self.to_y_pixel(value) + y = self.to_y_pixel(y_axis, value) self.painter.drawLine(self.layout.chart_left, y, self.layout.chart_right, y) def draw_x_axis(self, x_ticks): @@ -69,7 +69,7 @@ def draw_x_axis(self, x_ticks): text_y = y + 10 - text_rect.y() self.painter.drawText(text_x, text_y, label) - def draw_y_axis(self, y_ticks): + def draw_y_axis(self, y_axis, y_ticks): """ Draw the Y-axis. """ pen = QtGui.QPen(Qt.black) pen.setWidth(2) @@ -77,7 +77,7 @@ def draw_y_axis(self, y_ticks): x = self.layout.chart_right + 5 self.painter.drawLine(x, self.layout.chart_top, x, self.layout.chart_bottom) for value, label in y_ticks: - y = self.to_y_pixel(value) + y = self.to_y_pixel(y_axis, value) # Tick handle: self.painter.drawLine(x, y, x + 5, y) diff --git a/python/lognplot/qt/render/chart.py b/python/lognplot/qt/render/chart.py index 5ec65b1..ea15a5d 100644 --- a/python/lognplot/qt/render/chart.py +++ b/python/lognplot/qt/render/chart.py @@ -1,5 +1,5 @@ from ..qtapi import QtGui, QtCore, Qt -from ...chart import Chart +from ...chart import Axis, Chart from ...utils import bench_it from ...tsdb import Aggregation from .layout import ChartLayout @@ -25,16 +25,21 @@ def render(self): y_ticks = self.calc_y_ticks(self.chart.y_axis) if self.options.show_grid: - self.draw_grid(x_ticks, y_ticks) + self.draw_grid(self.chart.y_axis, x_ticks, y_ticks) self.draw_bouding_rect() if self.options.show_axis: self.draw_x_axis(x_ticks) - self.draw_y_axis(y_ticks) + self.draw_y_axis(self.chart.y_axis, y_ticks) + + if self.options.show_handles: + self._draw_handles() self._draw_curves() - self._draw_legend() + + if self.options.show_legend: + self._draw_legend() self._draw_cursor() def shade_region(self, region): @@ -77,17 +82,17 @@ def _draw_curve(self, curve): if data: if isinstance(data[0], Aggregation): - self._draw_aggregations_as_shape(data, curve_color) + curve.average = self._draw_aggregations_as_shape(curve.axis, data, curve_color) else: - self._draw_samples_as_lines(data, curve_color) + curve.average = self._draw_samples_as_lines(curve.axis, data, curve_color) - def _draw_samples_as_lines(self, samples, curve_color: QtGui.QColor): + def _draw_samples_as_lines(self, y_axis: Axis, samples, curve_color: QtGui.QColor): """ Draw raw samples as lines! """ pen = QtGui.QPen(curve_color) pen.setWidth(2) self.painter.setPen(pen) points = [ - QtCore.QPoint(self.to_x_pixel(x), self.to_y_pixel(y)) for (x, y) in samples + QtCore.QPoint(self.to_x_pixel(x), self.to_y_pixel(y_axis, y)) for (x, y) in samples ] line = QtGui.QPolygon(points) self.painter.drawPolyline(line) @@ -97,8 +102,10 @@ def _draw_samples_as_lines(self, samples, curve_color: QtGui.QColor): rect = QtCore.QRect(point.x() - 3, point.y() - 3, 6, 6) self.painter.drawEllipse(rect) + return sum(p.y() for p in points) / len(points) + def _draw_aggregations_as_shape( - self, aggregations: Aggregation, curve_color: QtGui.QColor + self, y_axis: Axis, aggregations: Aggregation, curve_color: QtGui.QColor ): """ Draw aggregates as polygon shapes. @@ -119,12 +126,12 @@ def _draw_aggregations_as_shape( # x2 = self.to_x_pixel(metric.x2) # max line: - y_max = self.to_y_pixel(aggregation.metrics.maximum) + y_max = self.to_y_pixel(y_axis, aggregation.metrics.maximum) max_points.append(QtCore.QPoint(x1, y_max)) # max_points.append(QtCore.QPoint(x2, y_max)) # min line: - y_min = self.to_y_pixel(aggregation.metrics.minimum) + y_min = self.to_y_pixel(y_axis, aggregation.metrics.minimum) min_points.append(QtCore.QPoint(x1, y_min)) # min_points.append(QtCore.QPoint(x2, y_min)) @@ -132,17 +139,17 @@ def _draw_aggregations_as_shape( stddev = aggregation.metrics.stddev # Mean line: - y_mean = self.to_y_pixel(mean) + y_mean = self.to_y_pixel(y_axis, mean) mean_points.append(QtCore.QPoint(x1, y_mean)) # mean_points.append(QtCore.QPoint(x2, y_mean)) # stddev up line: - y_stddev_up = self.to_y_pixel(mean + stddev) + y_stddev_up = self.to_y_pixel(y_axis, mean + stddev) stddev_up_points.append(QtCore.QPoint(x1, y_stddev_up)) # stddev_up_points.append(QtCore.QPoint(x2, y_stddev_up)) # stddev down line: - y_stddev_down = self.to_y_pixel(mean - stddev) + y_stddev_down = self.to_y_pixel(y_axis, mean - stddev) stddev_down_points.append(QtCore.QPoint(x1, y_stddev_down)) # stddev_down_points.append(QtCore.QPoint(x2, y_stddev_down)) @@ -188,6 +195,8 @@ def _draw_aggregations_as_shape( min_line = QtGui.QPolygon(min_points) self.painter.drawPolyline(min_line) + return (sum(p.y() for p in mean_points) / len(mean_points)) + def _draw_legend(self): """ Draw names / color of the curve next to eachother. """ @@ -240,7 +249,7 @@ def _draw_cursor(self): pen.setWidth(2) self.painter.setPen(pen) marker_x = self.to_x_pixel(curve_point_timestamp) - marker_y = self.to_y_pixel(curve_point_value) + marker_y = self.to_y_pixel(curve.axis, curve_point_value) marker_size = 10 indicator_rect = QtCore.QRect( marker_x - marker_size // 2, @@ -267,11 +276,36 @@ def _draw_cursor(self): color, ) + def _draw_handles(self): + x = self.layout.handles.left() + + for _, curve in enumerate(self.chart.curves): + handle_y = curve.average + x_full = self.options.handle_width + x_half = x_full / 2 + y_half = self.options.handle_height / 2 + + curve.handle = [ + QtCore.QPointF(x, handle_y - y_half), + QtCore.QPointF(x, handle_y - y_half), + QtCore.QPointF(x + x_half, handle_y - y_half), + QtCore.QPointF(x + x_full, handle_y), + QtCore.QPointF(x + x_half, handle_y + y_half), + QtCore.QPointF(x, handle_y + y_half) + ] + + polygon = QtGui.QPainterPath(curve.handle[0]) + for p in curve.handle[1:]: + polygon.lineTo(p) + + color = QtGui.QColor(curve.color) + self.painter.fillPath(polygon, QtGui.QBrush(color)) + def to_x_pixel(self, value): return transform.to_x_pixel(value, self.chart.x_axis, self.layout) - def to_y_pixel(self, value): - return transform.to_y_pixel(value, self.chart.y_axis, self.layout) + def to_y_pixel(self, y_axis, value): + return transform.to_y_pixel(value, y_axis, self.layout) def x_pixel_to_domain(self, pixel): axis = self.chart.x_axis diff --git a/python/lognplot/qt/render/layout.py b/python/lognplot/qt/render/layout.py index b9b5e20..1f0a84f 100644 --- a/python/lognplot/qt/render/layout.py +++ b/python/lognplot/qt/render/layout.py @@ -12,6 +12,11 @@ def __init__(self, rect: QtCore.QRect, options): # print(rect, type(rect)) self.rect = rect + self.handles = QtCore.QRect(self.rect.left() + self.options.padding, + self.rect.top(), + self.options.handle_width, + self.rect.height()) + # Endless sea of variables :) self.do_layout() @@ -19,7 +24,12 @@ def do_layout(self): # self.right = self.rect.right() # self.bottom = self.rect.bottom() self.chart_top = self.rect.top() + self.options.padding - self.chart_left = self.rect.left() + self.options.padding + + if self.options.show_handles: + self.chart_left = self.handles.right() + 3 + else: + self.chart_left = self.rect.left() + self.options.padding + if self.options.show_axis: axis_height = self.axis_height axis_width = self.axis_width diff --git a/python/lognplot/qt/render/options.py b/python/lognplot/qt/render/options.py index e355f84..6bf6e33 100644 --- a/python/lognplot/qt/render/options.py +++ b/python/lognplot/qt/render/options.py @@ -2,4 +2,9 @@ class ChartOptions: def __init__(self): self.show_axis = True self.show_grid = True + self.show_legend = False + self.show_handles = True + self.autoscale_y_axis = False self.padding = 10 + self.handle_width = 20 + self.handle_height = 15 \ No newline at end of file diff --git a/python/lognplot/qt/widgets/basewidget.py b/python/lognplot/qt/widgets/basewidget.py index 71e01ef..9baca82 100644 --- a/python/lognplot/qt/widgets/basewidget.py +++ b/python/lognplot/qt/widgets/basewidget.py @@ -24,6 +24,7 @@ def mousePressEvent(self, event): super().mousePressEvent(event) self.disable_tailing() self._mouse_drag_source = event.x(), event.y() + self.mousePress(event.x(), event.y()) self.update() def mouseMoveEvent(self, event): @@ -36,6 +37,7 @@ def mouseReleaseEvent(self, event): super().mouseReleaseEvent(event) self._update_mouse_pan(event.x(), event.y()) self._mouse_drag_source = None + self.mouseRelease(event.x(), event.y()) def _update_mouse_pan(self, x, y): if self._mouse_drag_source: @@ -43,12 +45,18 @@ def _update_mouse_pan(self, x, y): if x != x0 or y != y0: dy = y - y0 dx = x - x0 + self.mouseDrag(x, y, dx, dy) self.pan(dx, dy) self._mouse_drag_source = (x, y) self.update() - def mouse_move(self, x, y): - """ Intended for override. """ + def mousePress(self, x, y): + pass + + def mouseRelease(self, x, y): + pass + + def mouseDrag(self, x, y, dx, dy): pass def pan(self, dx, dy): diff --git a/python/lognplot/qt/widgets/chartwidget.py b/python/lognplot/qt/widgets/chartwidget.py index 8c99a97..1c56b63 100644 --- a/python/lognplot/qt/widgets/chartwidget.py +++ b/python/lognplot/qt/widgets/chartwidget.py @@ -7,7 +7,7 @@ from ..qtapi import QtCore, QtWidgets, QtGui, Qt, pyqtSignal from ...utils import bench_it -from ...chart import Chart +from ...chart import Chart, Curve from ..render import render_chart_on_qpainter, ChartLayout, ChartOptions from ..render import transform from . import mime @@ -78,11 +78,34 @@ def mouse_move(self, x, y): self.chart.set_cursor(value) self.update() + def curveHandleAtPoint(self, x, y) -> Curve: + for curve in self.chart.curves: + topleft = curve.handle[0] + middleright = curve.handle[3] + bottomleft = curve.handle[-1] + if (x >= topleft.x() and + x <= middleright.x() and + y >= topleft.y() and + y <= bottomleft.y() + ): + return curve + return None + + # Mouse interactions: + def mousePress(self, x, y): + curve = self.curveHandleAtPoint(x,y) + if curve is not None: + self._drag_handle = curve + self.chart.change_active_curve(curve) + def pan(self, dx, dy): # print("pan", dx, dy) shift = transform.x_pixels_to_domain(dx, self.chart.x_axis, self.chart_layout) self.chart.horizontal_pan_absolute(-shift) - self.chart.autoscale_y() + if self.chart_options.autoscale_y_axis: + self.chart.autoscale_y() + else: + self._drag_handle.axis.pan_relative(dy / self.rect().height()) self.update() def add_curve(self, name, color=None): @@ -109,16 +132,19 @@ def horizontal_zoom(self, amount, around): self.chart.horizontal_zoom(amount, around) # Autoscale Y for a nice effect? self.chart.autoscale_y() + self.repaint() self.update() def vertical_zoom(self, amount): self.chart.vertical_zoom(amount) + self.repaint() self.update() def horizontal_pan(self, amount): self.chart.horizontal_pan_relative(amount) # Autoscale Y for a nice effect? self.chart.autoscale_y() + self.repaint() self.update() def vertical_pan(self, amount): @@ -128,12 +154,14 @@ def vertical_pan(self, amount): def zoom_fit(self): """ Autoscale all in fit! """ self.chart.zoom_fit() + self.repaint() self.update() def zoom_to_last(self, span): """ Zoom to fit the last x time in view. """ self.chart.zoom_to_last(span) + self.repaint() self.update() def enable_tailing(self, timespan):