From 75375feb0e85dfe737fff8beae4f56bb0852e518 Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Sat, 4 Jan 2020 22:39:04 +0100 Subject: [PATCH 1/9] Draw grid behind bounding rectangle for cleaner look. --- python/lognplot/qt/render/chart.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/lognplot/qt/render/chart.py b/python/lognplot/qt/render/chart.py index 1024ca7..e63428b 100644 --- a/python/lognplot/qt/render/chart.py +++ b/python/lognplot/qt/render/chart.py @@ -21,14 +21,14 @@ def __init__( def render(self): """ Main entry point to start rendering a graph. """ - self.draw_bouding_rect() - x_ticks = self.calc_x_ticks(self.chart.x_axis) y_ticks = self.calc_y_ticks(self.chart.y_axis) if self.options.show_grid: self.draw_grid(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) From 9639985278faaa324ad979124c3082ca8bc7c55b Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Sat, 4 Jan 2020 23:39:37 +0100 Subject: [PATCH 2/9] Added handles left of the chart area corresponding to the plotted signals. --- python/lognplot/chart/curve.py | 1 + python/lognplot/qt/render/chart.py | 38 +++++++++++++++++++++++++--- python/lognplot/qt/render/layout.py | 12 ++++++++- python/lognplot/qt/render/options.py | 4 +++ 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/python/lognplot/chart/curve.py b/python/lognplot/chart/curve.py index 4c2f9c1..3664495 100644 --- a/python/lognplot/chart/curve.py +++ b/python/lognplot/chart/curve.py @@ -14,6 +14,7 @@ def __init__(self, db, name, color): self._db = db self.name = name self.color = color + self.average = 0 # Average of the visual part of the curve def __repr__(self): return "Database proxy-curve" diff --git a/python/lognplot/qt/render/chart.py b/python/lognplot/qt/render/chart.py index e63428b..20b8945 100644 --- a/python/lognplot/qt/render/chart.py +++ b/python/lognplot/qt/render/chart.py @@ -33,8 +33,13 @@ def render(self): self.draw_x_axis(x_ticks) self.draw_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() def shade_region(self, region): """ Draw a shaded box in some region. @@ -76,9 +81,9 @@ 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(data, curve_color) else: - self._draw_samples_as_lines(data, curve_color) + curve.average = self._draw_samples_as_lines(data, curve_color) def _draw_samples_as_lines(self, samples, curve_color: QtGui.QColor): """ Draw raw samples as lines! """ @@ -96,6 +101,8 @@ 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 ): @@ -187,6 +194,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. """ @@ -212,6 +221,29 @@ def _draw_legend(self): color, ) + def _draw_handles(self): + x = self.layout.handles.left() + y = self.layout.handles.top() + self.layout.handles.height() / 3 + + for _, curve in enumerate(self.chart.curves): + handle_y = curve.average + x_full = self.options.handle_width #self.layout.handles.right() + x_half = x_full / 2 + y_full = self.options.handle_height + y_half = y_full / 2 + + polygon = QtGui.QPainterPath(QtCore.QPoint(x, handle_y)) + polygon.lineTo(QtCore.QPoint(x, handle_y)) + polygon.lineTo(QtCore.QPoint(x + x_half, handle_y)) + polygon.lineTo(QtCore.QPoint(x + x_full, handle_y + y_half)) + polygon.lineTo(QtCore.QPoint(x + x_half, handle_y + y_full)) + polygon.lineTo(QtCore.QPoint(x, handle_y + y_full)) + + color = QtGui.QColor(curve.color) + self.painter.fillPath(polygon, QtGui.QBrush(color)) + + handle_y = handle_y + self.options.handle_height + def to_x_pixel(self, value): return transform.to_x_pixel(value, self.chart.x_axis, self.layout) diff --git a/python/lognplot/qt/render/layout.py b/python/lognplot/qt/render/layout.py index b9b5e20..8d8590b 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() + 1 + 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..dc20b2c 100644 --- a/python/lognplot/qt/render/options.py +++ b/python/lognplot/qt/render/options.py @@ -2,4 +2,8 @@ class ChartOptions: def __init__(self): self.show_axis = True self.show_grid = True + self.show_legend = False + self.show_handles = True self.padding = 10 + self.handle_width = 20 + self.handle_height = 15 \ No newline at end of file From ce4ce7ccee1bc854d5cf2ed29b272f68486eb811 Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Sat, 4 Jan 2020 23:59:52 +0100 Subject: [PATCH 3/9] Force repaint on panning / zooming. --- python/lognplot/qt/render/chart.py | 20 ++++++++------------ python/lognplot/qt/widgets/chartwidget.py | 7 +++++++ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/python/lognplot/qt/render/chart.py b/python/lognplot/qt/render/chart.py index 20b8945..7bc38a6 100644 --- a/python/lognplot/qt/render/chart.py +++ b/python/lognplot/qt/render/chart.py @@ -223,27 +223,23 @@ def _draw_legend(self): def _draw_handles(self): x = self.layout.handles.left() - y = self.layout.handles.top() + self.layout.handles.height() / 3 for _, curve in enumerate(self.chart.curves): handle_y = curve.average - x_full = self.options.handle_width #self.layout.handles.right() + x_full = self.options.handle_width x_half = x_full / 2 - y_full = self.options.handle_height - y_half = y_full / 2 + y_half = self.options.handle_height / 2 - polygon = QtGui.QPainterPath(QtCore.QPoint(x, handle_y)) - polygon.lineTo(QtCore.QPoint(x, handle_y)) - polygon.lineTo(QtCore.QPoint(x + x_half, handle_y)) - polygon.lineTo(QtCore.QPoint(x + x_full, handle_y + y_half)) - polygon.lineTo(QtCore.QPoint(x + x_half, handle_y + y_full)) - polygon.lineTo(QtCore.QPoint(x, handle_y + y_full)) + polygon = QtGui.QPainterPath(QtCore.QPointF(x, handle_y - y_half)) + polygon.lineTo(QtCore.QPointF(x, handle_y - y_half)) + polygon.lineTo(QtCore.QPointF(x + x_half, handle_y - y_half)) + polygon.lineTo(QtCore.QPointF(x + x_full, handle_y)) + polygon.lineTo(QtCore.QPointF(x + x_half, handle_y + y_half)) + polygon.lineTo(QtCore.QPointF(x, handle_y + y_half)) color = QtGui.QColor(curve.color) self.painter.fillPath(polygon, QtGui.QBrush(color)) - handle_y = handle_y + self.options.handle_height - def to_x_pixel(self, value): return transform.to_x_pixel(value, self.chart.x_axis, self.layout) diff --git a/python/lognplot/qt/widgets/chartwidget.py b/python/lognplot/qt/widgets/chartwidget.py index 29809a2..7ca9a52 100644 --- a/python/lognplot/qt/widgets/chartwidget.py +++ b/python/lognplot/qt/widgets/chartwidget.py @@ -53,6 +53,7 @@ def pan(self, dx, dy): print("pan", dx, dy) options1 = ChartOptions() layout = ChartLayout(self.rect(), options1) + self.repaint() def add_curve(self, name, color=None): if not self.chart.has_curve(name): @@ -76,31 +77,37 @@ def horizontal_zoom(self, amount): self.chart.horizontal_zoom(amount) # 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(amount) # Autoscale Y for a nice effect? self.chart.autoscale_y() + self.repaint() self.update() def vertical_pan(self, amount): self.chart.vertical_pan(amount) + self.repaint() self.update() 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): From 9a4ffd78147055649b60275ef6d25e71134288e4 Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Sun, 5 Jan 2020 01:55:22 +0100 Subject: [PATCH 4/9] Working vertical pan using mouse on handles (including bug) --- python/lognplot/chart/curve.py | 7 +++- python/lognplot/qt/render/chart.py | 42 ++++++++++++++--------- python/lognplot/qt/widgets/basewidget.py | 12 +++++++ python/lognplot/qt/widgets/chartwidget.py | 40 ++++++++++++++++++--- 4 files changed, 79 insertions(+), 22 deletions(-) diff --git a/python/lognplot/chart/curve.py b/python/lognplot/chart/curve.py index 3664495..3ce2cdd 100644 --- a/python/lognplot/chart/curve.py +++ b/python/lognplot/chart/curve.py @@ -14,7 +14,12 @@ def __init__(self, db, name, color): self._db = db self.name = name self.color = color - self.average = 0 # Average of the visual part of the curve + # Average of the visual part of the curve + self.average = 0 + # Corresponding handle (polygon area) + self.handle = [] + # Vertical adjustment by user + self.vertical_offset = 0 def __repr__(self): return "Database proxy-curve" diff --git a/python/lognplot/qt/render/chart.py b/python/lognplot/qt/render/chart.py index 7bc38a6..7b4cfe3 100644 --- a/python/lognplot/qt/render/chart.py +++ b/python/lognplot/qt/render/chart.py @@ -78,20 +78,22 @@ def _draw_curve(self, curve): data = curve.query(timespan, min_count) # print("query result", type(data), len(data)) curve_color = QtGui.QColor(curve.color) + # vertical offset + v_offset = curve.vertical_offset if data: if isinstance(data[0], Aggregation): - curve.average = self._draw_aggregations_as_shape(data, curve_color) + curve.average = self._draw_aggregations_as_shape(data, curve_color, v_offset) else: - curve.average = self._draw_samples_as_lines(data, curve_color) + curve.average = self._draw_samples_as_lines(data, curve_color, v_offset) - def _draw_samples_as_lines(self, samples, curve_color: QtGui.QColor): + def _draw_samples_as_lines(self, samples, curve_color: QtGui.QColor, v_offset): """ 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) + v_offset) for (x, y) in samples ] line = QtGui.QPolygon(points) self.painter.drawPolyline(line) @@ -104,7 +106,7 @@ def _draw_samples_as_lines(self, samples, curve_color: QtGui.QColor): return sum(p.y() for p in points) / len(points) def _draw_aggregations_as_shape( - self, aggregations: Aggregation, curve_color: QtGui.QColor + self, aggregations: Aggregation, curve_color: QtGui.QColor, v_offset ): """ Draw aggregates as polygon shapes. @@ -125,12 +127,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(aggregation.metrics.maximum) + v_offset 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(aggregation.metrics.minimum) + v_offset min_points.append(QtCore.QPoint(x1, y_min)) # min_points.append(QtCore.QPoint(x2, y_min)) @@ -138,17 +140,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(mean) + v_offset 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(mean + stddev) + v_offset 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(mean - stddev) + v_offset stddev_down_points.append(QtCore.QPoint(x1, y_stddev_down)) # stddev_down_points.append(QtCore.QPoint(x2, y_stddev_down)) @@ -194,7 +196,7 @@ 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) + 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. @@ -230,12 +232,18 @@ def _draw_handles(self): x_half = x_full / 2 y_half = self.options.handle_height / 2 - polygon = QtGui.QPainterPath(QtCore.QPointF(x, handle_y - y_half)) - polygon.lineTo(QtCore.QPointF(x, handle_y - y_half)) - polygon.lineTo(QtCore.QPointF(x + x_half, handle_y - y_half)) - polygon.lineTo(QtCore.QPointF(x + x_full, handle_y)) - polygon.lineTo(QtCore.QPointF(x + x_half, handle_y + y_half)) - polygon.lineTo(QtCore.QPointF(x, handle_y + y_half)) + 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)) diff --git a/python/lognplot/qt/widgets/basewidget.py b/python/lognplot/qt/widgets/basewidget.py index 80a438f..d50d867 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): @@ -34,6 +35,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: @@ -41,10 +43,20 @@ 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 mousePress(self, x, y): + pass + + def mouseRelease(self, x, y): + pass + + def mouseDrag(self, x, y, dx, dy): + pass + def pan(self, dx, dy): """ Intended for subclasses to override. """ diff --git a/python/lognplot/qt/widgets/chartwidget.py b/python/lognplot/qt/widgets/chartwidget.py index 7ca9a52..e5a7a15 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 . import mime from .basewidget import BaseWidget @@ -29,6 +29,8 @@ def __init__(self, db): # Accept drop of signal names self.setAcceptDrops(True) + self._drag_handle = None + # Tailing mode, select last t seconds self._last_span = None self._tailing_timer = QtCore.QTimer() @@ -48,12 +50,42 @@ def dropEvent(self, event): self.logger.debug(f"Add curve {name}") self.add_curve(name) + 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): + if self._drag_handle is None: + curve = self.curveHandleAtPoint(x,y) + if curve is not None: + self._drag_handle = curve + + def mouseRelease(self, x, y): + self._drag_handle = None + + def mouseDrag(self, x, y, dx, dy): + if self._drag_handle is not None: + #self.vertical_pan(float(dy) * 0.01) #self.PAN_FACTOR) + self._drag_handle.vertical_offset = self._drag_handle.vertical_offset + dy + self.repaint() + + # Intended to work together with the WIP minimap? def pan(self, dx, dy): print("pan", dx, dy) - options1 = ChartOptions() - layout = ChartLayout(self.rect(), options1) - self.repaint() + # TODO: fix + #options1 = ChartOptions() + #layout = ChartLayout(self.rect(), options1) + #self.repaint() def add_curve(self, name, color=None): if not self.chart.has_curve(name): From 50e41e8a3f25491a6d01ea18d64b9ee3e168d5f1 Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Sun, 5 Jan 2020 23:54:54 +0100 Subject: [PATCH 5/9] Supplied each curve with its own vertical axis; thus, multi-axis support. --- python/lognplot/chart/chart.py | 7 +++++ python/lognplot/chart/curve.py | 6 ++--- python/lognplot/qt/render/__init__.py | 2 +- python/lognplot/qt/render/base.py | 8 +++--- python/lognplot/qt/render/chart.py | 32 +++++++++++------------ python/lognplot/qt/widgets/chartwidget.py | 4 +-- 6 files changed, 32 insertions(+), 27 deletions(-) diff --git a/python/lognplot/chart/chart.py b/python/lognplot/chart/chart.py index 449ed21..641c957 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.db = db def has_curve(self, name): @@ -30,9 +31,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 3ce2cdd..73e3c39 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. @@ -18,8 +18,8 @@ def __init__(self, db, name, color): self.average = 0 # Corresponding handle (polygon area) self.handle = [] - # Vertical adjustment by user - self.vertical_offset = 0 + # Each curve has its own vertical axis + self.axis = Axis() def __repr__(self): return "Database proxy-curve" diff --git a/python/lognplot/qt/render/__init__.py b/python/lognplot/qt/render/__init__.py index bfe5eca..4f695b3 100644 --- a/python/lognplot/qt/render/__init__.py +++ b/python/lognplot/qt/render/__init__.py @@ -5,7 +5,7 @@ from .render import Renderer from .layout import ChartLayout from .options import ChartOptions - +from .transform import * def render_chart_on_qpainter(chart: Chart, painter: QtGui.QPainter, rect: QtCore.QRect): """ Call this function to paint a chart onto the given painter within the rectangle specified. 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 7b4cfe3..6487d9c 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,13 +25,13 @@ 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() @@ -78,22 +78,20 @@ def _draw_curve(self, curve): data = curve.query(timespan, min_count) # print("query result", type(data), len(data)) curve_color = QtGui.QColor(curve.color) - # vertical offset - v_offset = curve.vertical_offset if data: if isinstance(data[0], Aggregation): - curve.average = self._draw_aggregations_as_shape(data, curve_color, v_offset) + curve.average = self._draw_aggregations_as_shape(curve.axis, data, curve_color) else: - curve.average = self._draw_samples_as_lines(data, curve_color, v_offset) + curve.average = self._draw_samples_as_lines(curve.axis, data, curve_color) - def _draw_samples_as_lines(self, samples, curve_color: QtGui.QColor, v_offset): + 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) + v_offset) 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) @@ -106,7 +104,7 @@ def _draw_samples_as_lines(self, samples, curve_color: QtGui.QColor, v_offset): return sum(p.y() for p in points) / len(points) def _draw_aggregations_as_shape( - self, aggregations: Aggregation, curve_color: QtGui.QColor, v_offset + self, y_axis: Axis, aggregations: Aggregation, curve_color: QtGui.QColor ): """ Draw aggregates as polygon shapes. @@ -127,12 +125,12 @@ def _draw_aggregations_as_shape( # x2 = self.to_x_pixel(metric.x2) # max line: - y_max = self.to_y_pixel(aggregation.metrics.maximum) + v_offset + 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) + v_offset + 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)) @@ -140,17 +138,17 @@ def _draw_aggregations_as_shape( stddev = aggregation.metrics.stddev # Mean line: - y_mean = self.to_y_pixel(mean) + v_offset + 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) + v_offset + 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) + v_offset + 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)) @@ -251,8 +249,8 @@ def _draw_handles(self): 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/widgets/chartwidget.py b/python/lognplot/qt/widgets/chartwidget.py index e5a7a15..20d660c 100644 --- a/python/lognplot/qt/widgets/chartwidget.py +++ b/python/lognplot/qt/widgets/chartwidget.py @@ -69,14 +69,14 @@ 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 mouseRelease(self, x, y): self._drag_handle = None def mouseDrag(self, x, y, dx, dy): if self._drag_handle is not None: - #self.vertical_pan(float(dy) * 0.01) #self.PAN_FACTOR) - self._drag_handle.vertical_offset = self._drag_handle.vertical_offset + dy + self._drag_handle.axis.pan(dy / self.rect().height()) self.repaint() # Intended to work together with the WIP minimap? From 38e3cf7ec497db420cd68311b7490314f9990f88 Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Mon, 6 Jan 2020 00:06:54 +0100 Subject: [PATCH 6/9] Increase clearance between signal handles and plot area. --- python/lognplot/qt/render/layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/lognplot/qt/render/layout.py b/python/lognplot/qt/render/layout.py index 8d8590b..1f0a84f 100644 --- a/python/lognplot/qt/render/layout.py +++ b/python/lognplot/qt/render/layout.py @@ -26,7 +26,7 @@ def do_layout(self): self.chart_top = self.rect.top() + self.options.padding if self.options.show_handles: - self.chart_left = self.handles.right() + 1 + self.chart_left = self.handles.right() + 3 else: self.chart_left = self.rect.left() + self.options.padding From 28336ad4e68c66237095caae6b244c719fb5332a Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Mon, 6 Jan 2020 00:30:24 +0100 Subject: [PATCH 7/9] Merge with upstream cursor feature. --- lognplot/src/chart/axis.rs | 23 ++++++++-- lognplot/src/chart/chart.rs | 8 ++-- lognplot/src/render/mod.rs | 15 ++++++ lognplotgtk/src/chart_widget.rs | 11 +++-- lognplotgtk/src/state.rs | 30 ++++++++---- python/docs/index.rst | 2 +- python/docs/reference.rst | 37 --------------- python/docs/reference/chart.rst | 11 +++++ python/docs/reference/index.rst | 13 ++++++ python/docs/reference/misc.rst | 16 +++++++ python/docs/reference/tsdb.rst | 15 ++++++ python/lognplot/__main__.py | 5 ++ python/lognplot/chart/axis.py | 35 ++++++++++++-- python/lognplot/chart/chart.py | 25 +++++++--- python/lognplot/chart/curve.py | 3 ++ python/lognplot/qt/render/__init__.py | 6 +-- python/lognplot/qt/render/chart.py | 55 ++++++++++++++++++++++ python/lognplot/qt/render/render.py | 14 ++++-- python/lognplot/qt/render/transform.py | 17 +++++++ python/lognplot/qt/widgets/basewidget.py | 14 +++--- python/lognplot/qt/widgets/chartwidget.py | 56 +++++++++++++++++------ python/lognplot/qt/widgets/eventwidget.py | 6 +-- python/lognplot/qt/widgets/logwidget.py | 6 +-- python/lognplot/time/timespan.py | 1 + python/lognplot/tsdb/btree.py | 37 +++++++++++++++ python/lognplot/tsdb/db.py | 4 ++ python/lognplot/tsdb/metrics.py | 11 ++++- python/lognplot/tsdb/series.py | 3 ++ python/setup.py | 2 +- 29 files changed, 375 insertions(+), 106 deletions(-) delete mode 100644 python/docs/reference.rst create mode 100644 python/docs/reference/chart.rst create mode 100644 python/docs/reference/index.rst create mode 100644 python/docs/reference/misc.rst create mode 100644 python/docs/reference/tsdb.rst diff --git a/lognplot/src/chart/axis.rs b/lognplot/src/chart/axis.rs index 226fe25..0d86c7c 100644 --- a/lognplot/src/chart/axis.rs +++ b/lognplot/src/chart/axis.rs @@ -45,7 +45,8 @@ impl ValueAxis { self.end() - self.begin() } - pub fn zoom(&mut self, amount: f64) { + /// Zoom the axis by a certain percentage, optionally centered around some value. + pub fn zoom(&mut self, amount: f64, around: Option) { let domain = self.domain(); if (domain < 1.0e-18) && (amount < 0.0) { return; @@ -55,9 +56,23 @@ impl ValueAxis { return; } - let step = domain * amount; - let begin = self.begin() - step; - let end = self.end() + step; + let (left_percent, right_percent) = if let Some(around) = around { + if self.begin() < around && around < self.end() { + let left_percent = (around - self.begin()) / domain; + assert!(left_percent < 1.0); + let right_percent = 1.0 - left_percent; + (left_percent, right_percent) + } else { + (0.5, 0.5) + } + } else { + (0.5, 0.5) + }; + + let step = domain * amount * 2.0; + let begin = self.begin() - step * left_percent; + let end = self.end() + step * right_percent; + self.set_limits(begin, end); } diff --git a/lognplot/src/chart/chart.rs b/lognplot/src/chart/chart.rs index f6274be..9115a59 100644 --- a/lognplot/src/chart/chart.rs +++ b/lognplot/src/chart/chart.rs @@ -2,7 +2,7 @@ use super::axis::ValueAxis; use super::curve::Curve; -use crate::time::TimeSpan; +use crate::time::{TimeSpan, TimeStamp}; use crate::tsdb::{Aggregation, Sample, SampleMetrics}; /// A single 2D-chart @@ -58,13 +58,13 @@ impl Chart { } /// Zoom horizontally. - pub fn zoom_horizontal(&mut self, amount: f64) { - self.x_axis.zoom(amount); + pub fn zoom_horizontal(&mut self, amount: f64, around: Option) { + self.x_axis.zoom(amount, around); } /// Perform vertical zooming pub fn zoom_vertical(&mut self, amount: f64) { - self.y_axis.zoom(amount); + self.y_axis.zoom(amount, None); } /// Perform a bit of relative horizontal panning diff --git a/lognplot/src/render/mod.rs b/lognplot/src/render/mod.rs index 3ebd517..63aaec7 100644 --- a/lognplot/src/render/mod.rs +++ b/lognplot/src/render/mod.rs @@ -23,6 +23,7 @@ pub use cairo_canvas::CairoCanvas; use crate::chart::ValueAxis; use crate::geometry::Size; +use crate::time::TimeStamp; /// Calculate how many domain values a covered by the given amount of pixels. pub fn x_pixels_to_domain(size: Size, axis: &ValueAxis, pixels: f64) -> f64 { @@ -37,3 +38,17 @@ pub fn x_pixels_to_domain(size: Size, axis: &ValueAxis, pixels: f64) -> f64 { pixels * a } } + +pub fn x_pixel_to_domain(pixel: f64, axis: &ValueAxis, size: Size) -> f64 { + let options = ChartOptions::default(); + let mut layout = ChartLayout::new(size); + layout.layout(&options); + + let domain = axis.domain(); + if layout.plot_width < 1.0 { + 0.0 + } else { + let a = domain / layout.plot_width; + a * (pixel - layout.plot_left) + axis.begin() + } +} diff --git a/lognplotgtk/src/chart_widget.rs b/lognplotgtk/src/chart_widget.rs index a328f1b..3258545 100644 --- a/lognplotgtk/src/chart_widget.rs +++ b/lognplotgtk/src/chart_widget.rs @@ -62,12 +62,15 @@ pub fn setup_drawing_area(draw_area: gtk::DrawingArea, app_state: GuiStateHandle draw_area.connect_scroll_event(clone!(@strong app_state => move |w, e| { // println!("Scroll wheel event! {:?}, {:?}, {:?}", e, e.get_delta(), e.get_direction()); + let size = get_size(w); + let pixel_x_pos = e.get_position().0; + let around = Some((pixel_x_pos, size)); match e.get_direction() { gdk::ScrollDirection::Up => { - app_state.borrow_mut().zoom_in_horizontal(); + app_state.borrow_mut().zoom_in_horizontal(around); }, gdk::ScrollDirection::Down => { - app_state.borrow_mut().zoom_out_horizontal(); + app_state.borrow_mut().zoom_out_horizontal(around); }, _ => {} } @@ -155,10 +158,10 @@ fn on_key(draw_area: >k::DrawingArea, key: &gdk::EventKey, app_state: GuiState app_state.borrow_mut().zoom_out_vertical(); } gdk::enums::key::KP_Add | gdk::enums::key::l => { - app_state.borrow_mut().zoom_in_horizontal(); + app_state.borrow_mut().zoom_in_horizontal(None); } gdk::enums::key::KP_Subtract | gdk::enums::key::j => { - app_state.borrow_mut().zoom_out_horizontal(); + app_state.borrow_mut().zoom_out_horizontal(None); } gdk::enums::key::Home | gdk::enums::key::Return => { app_state.borrow_mut().zoom_fit(); diff --git a/lognplotgtk/src/state.rs b/lognplotgtk/src/state.rs index 61d9df4..bb97785 100644 --- a/lognplotgtk/src/state.rs +++ b/lognplotgtk/src/state.rs @@ -4,7 +4,7 @@ use std::time::Instant; use lognplot::chart::{Chart, Curve, CurveData}; use lognplot::geometry::Size; -use lognplot::render::x_pixels_to_domain; +use lognplot::render::{x_pixel_to_domain, x_pixels_to_domain}; use lognplot::time::TimeStamp; use lognplot::tsdb::{Aggregation, Observation, Sample, SampleMetrics, TsDbHandle}; @@ -184,27 +184,37 @@ impl GuiState { pub fn zoom_in_vertical(&mut self) { info!("Zoom in vertical"); - self.disable_tailing(); - self.chart.zoom_vertical(0.1); + self.zoom_vertical(0.1); } pub fn zoom_out_vertical(&mut self) { info!("Zoom out vertical"); + self.zoom_vertical(-0.1); + } + + fn zoom_vertical(&mut self, amount: f64) { self.disable_tailing(); - self.chart.zoom_vertical(-0.1); + self.chart.zoom_vertical(amount); } - pub fn zoom_in_horizontal(&mut self) { + pub fn zoom_in_horizontal(&mut self, around: Option<(f64, Size)>) { info!("Zoom in horizontal"); - self.disable_tailing(); - self.chart.zoom_horizontal(-0.1); - self.chart.fit_y_axis(); + self.zoom_horizontal(-0.1, around); } - pub fn zoom_out_horizontal(&mut self) { + pub fn zoom_out_horizontal(&mut self, around: Option<(f64, Size)>) { info!("Zoom out horizontal"); + self.zoom_horizontal(0.1, around); + } + + fn zoom_horizontal(&mut self, amount: f64, around: Option<(f64, Size)>) { + let around = around.map(|p| { + let (pixel, size) = p; + let timestamp = x_pixel_to_domain(pixel, &self.chart.x_axis, size); + timestamp + }); self.disable_tailing(); - self.chart.zoom_horizontal(0.1); + self.chart.zoom_horizontal(amount, around); self.chart.fit_y_axis(); } diff --git a/python/docs/index.rst b/python/docs/index.rst index 5b2c903..f23bbae 100644 --- a/python/docs/index.rst +++ b/python/docs/index.rst @@ -52,7 +52,7 @@ Table of Contents motivation architecture protocol - reference + reference/index.rst Indices and tables diff --git a/python/docs/reference.rst b/python/docs/reference.rst deleted file mode 100644 index 40662db..0000000 --- a/python/docs/reference.rst +++ /dev/null @@ -1,37 +0,0 @@ - - -Reference -========= - -.. automodule:: lognplot - :members: - -.. automodule:: lognplot.client - :members: - -.. automodule:: lognplot.chart.curve - :members: - -.. automodule:: lognplot.chart.chart - :members: - -.. automodule:: lognplot.chart.axis - :members: - -.. automodule:: lognplot.tsdb.db - :members: - -.. automodule:: lognplot.tsdb.aggregation - :members: - -.. automodule:: lognplot.tsdb.btree - :members: - -.. automodule:: lognplot.tsdb.metrics - :members: - -.. automodule:: lognplot.time.timespan - :members: - -.. automodule:: lognplot.time.duration - :members: diff --git a/python/docs/reference/chart.rst b/python/docs/reference/chart.rst new file mode 100644 index 0000000..6c9c05a --- /dev/null +++ b/python/docs/reference/chart.rst @@ -0,0 +1,11 @@ +Chart module reference +====================== + +.. automodule:: lognplot.chart.curve + :members: + +.. automodule:: lognplot.chart.chart + :members: + +.. automodule:: lognplot.chart.axis + :members: diff --git a/python/docs/reference/index.rst b/python/docs/reference/index.rst new file mode 100644 index 0000000..e34d3e0 --- /dev/null +++ b/python/docs/reference/index.rst @@ -0,0 +1,13 @@ + + +Reference +========= + + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + chart + tsdb + misc diff --git a/python/docs/reference/misc.rst b/python/docs/reference/misc.rst new file mode 100644 index 0000000..2da9040 --- /dev/null +++ b/python/docs/reference/misc.rst @@ -0,0 +1,16 @@ + +Misc modules +============ + +.. automodule:: lognplot + :members: + +.. automodule:: lognplot.client + :members: + + +.. automodule:: lognplot.time.timespan + :members: + +.. automodule:: lognplot.time.duration + :members: diff --git a/python/docs/reference/tsdb.rst b/python/docs/reference/tsdb.rst new file mode 100644 index 0000000..ecde337 --- /dev/null +++ b/python/docs/reference/tsdb.rst @@ -0,0 +1,15 @@ + +Time series database reference +============================== + +.. automodule:: lognplot.tsdb.db + :members: + +.. automodule:: lognplot.tsdb.aggregation + :members: + +.. automodule:: lognplot.tsdb.btree + :members: + +.. automodule:: lognplot.tsdb.metrics + :members: diff --git a/python/lognplot/__main__.py b/python/lognplot/__main__.py index bc14995..52ba78c 100644 --- a/python/lognplot/__main__.py +++ b/python/lognplot/__main__.py @@ -6,6 +6,7 @@ import argparse import logging +import sys def main(): @@ -30,6 +31,10 @@ def main(): loglevel = logging.INFO logging.basicConfig(level=loglevel) + logging.info("Python version: {}".format(sys.version)) + from .qt.qtapi import QtCore + + logging.info("Qt version: {}".format(QtCore.qVersion())) from .qt.apps import run_server_gui diff --git a/python/lognplot/chart/axis.py b/python/lognplot/chart/axis.py index 6d88003..b17812a 100644 --- a/python/lognplot/chart/axis.py +++ b/python/lognplot/chart/axis.py @@ -3,11 +3,18 @@ class Axis: + """ Implement an axis with a minimum and maximum value. + + This class can also be used to generate appropriate tick values + for the axis. + """ + def __init__(self): self.minimum = -30 self.maximum = 130 - def zoom(self, amount): + def zoom(self, amount, around=None): + """ Zoom this axis by a certain amount, optionally around the given value. """ domain = self.domain if domain < 1e-18 and amount < 0: return @@ -16,12 +23,26 @@ def zoom(self, amount): return step = domain * amount - self.minimum -= step - self.maximum += step + if around is not None and self.minimum < around < self.maximum: + left_part = (around - self.minimum) / domain + assert left_part < 1.0 + right_part = 1.0 - left_part + step_left = step * left_part + step_right = step * right_part + else: + step_left = step_right = step + + self.minimum -= step_left + self.maximum += step_right - def pan(self, amount): + def pan_relative(self, amount): + """ Pan a percentage of the axis range. """ domain = self.domain step = domain * amount + self.pan_absolute(step) + + def pan_absolute(self, step): + """ Move the axis view by an absolute amount. """ self.minimum += step self.maximum += step @@ -38,6 +59,12 @@ def get_timespan(self): return TimeSpan(begin, end) def get_ticks(self, n_ticks): + """ Get tick values for this axis. + + This function should take care of the following: + - tick values are rounded to logical multiples, such as 1, 2 or 0.2 + - tick values are returned as tuples of values and the string label. + """ domain = self.domain # Check for too small domain: diff --git a/python/lognplot/chart/chart.py b/python/lognplot/chart/chart.py index 641c957..e7cb4e7 100644 --- a/python/lognplot/chart/chart.py +++ b/python/lognplot/chart/chart.py @@ -19,6 +19,7 @@ def __init__(self, db): self.y_axis = Axis() self.curves = [] self.activeCurve = None + self.cursor = None self.db = db def has_curve(self, name): @@ -46,18 +47,30 @@ def info(self): for index, curve in enumerate(self.curves): print(f"serie {index} with {len(curve)} samples") - def horizontal_zoom(self, amount): + def set_cursor(self, value): + """ Set cursor position onto this chart. + + Use None to hide the cursor. + """ + self.cursor = value + + def horizontal_zoom(self, amount, around): """ Zoom in horizontal manner. """ - self.x_axis.zoom(amount) + self.x_axis.zoom(amount, around=around) def vertical_zoom(self, amount): self.y_axis.zoom(amount) - def horizontal_pan(self, amount): - self.x_axis.pan(amount) + def horizontal_pan_relative(self, amount): + """ Pan a percentage of the current axis range. """ + self.x_axis.pan_relative(amount) + + def horizontal_pan_absolute(self, amount): + """ Pan horizontally by a certain amount. """ + self.x_axis.pan_absolute(amount) - def vertical_pan(self, amount): - self.y_axis.pan(amount) + def vertical_pan_relative(self, amount): + self.y_axis.pan_relative(amount) def autoscale_y(self): """ Automatically adjust the Y-axis to fit data in range. """ diff --git a/python/lognplot/chart/curve.py b/python/lognplot/chart/curve.py index 73e3c39..cb8c96e 100644 --- a/python/lognplot/chart/curve.py +++ b/python/lognplot/chart/curve.py @@ -33,3 +33,6 @@ def query_summary(self, timespan=None) -> Aggregation: def query(self, selection_timespan, min_count): # TODO: cache calls here? return self._db.query(self.name, selection_timespan, min_count) + + def query_value(self, timestamp): + return self._db.query_value(self.name, timestamp) diff --git a/python/lognplot/qt/render/__init__.py b/python/lognplot/qt/render/__init__.py index 4f695b3..4ec4bcb 100644 --- a/python/lognplot/qt/render/__init__.py +++ b/python/lognplot/qt/render/__init__.py @@ -7,9 +7,9 @@ from .options import ChartOptions from .transform import * -def render_chart_on_qpainter(chart: Chart, painter: QtGui.QPainter, rect: QtCore.QRect): +def render_chart_on_qpainter(chart: Chart, painter: QtGui.QPainter, layout, options): """ Call this function to paint a chart onto the given painter within the rectangle specified. """ - renderer = Renderer(painter, chart) + renderer = Renderer(painter, chart, layout, options) # with bench_it("render"): - renderer.render(rect) + renderer.render() diff --git a/python/lognplot/qt/render/chart.py b/python/lognplot/qt/render/chart.py index 6487d9c..a5a2394 100644 --- a/python/lognplot/qt/render/chart.py +++ b/python/lognplot/qt/render/chart.py @@ -40,6 +40,7 @@ def render(self): if self.options.show_legend: self._draw_legend() + self._draw_cursor() def shade_region(self, region): """ Draw a shaded box in some region. @@ -221,6 +222,60 @@ def _draw_legend(self): color, ) + def _draw_cursor(self): + if self.chart.cursor: + # Draw cursor line: + x = self.to_x_pixel(self.chart.cursor) + pen = QtGui.QPen(Qt.black) + pen.setWidth(1) + self.painter.setPen(pen) + self.painter.drawLine(x, self.layout.chart_top, x, self.layout.chart_bottom) + + # Draw values of signals at position: + font_metrics = self.painter.fontMetrics() + legend_x = x + 10 + y = self.layout.chart_top + 10 + text_height = font_metrics.height() + color_block_size = text_height * 0.8 + for index, curve in enumerate(self.chart.curves): + color = QtGui.QColor(curve.color) + curve_point = curve.query_value(self.chart.cursor) + if not curve_point: + continue + curve_point_timestamp, curve_point_value = curve_point + + # Draw circle indicator around selected point: + pen = QtGui.QPen(color) + pen.setWidth(2) + self.painter.setPen(pen) + marker_x = self.to_x_pixel(curve_point_timestamp) + marker_y = self.to_y_pixel(self.chart.y_axis, curve_point_value) + marker_size = 10 + indicator_rect = QtCore.QRect( + marker_x - marker_size // 2, + marker_y - marker_size // 2, + marker_size, + marker_size, + ) + self.painter.drawEllipse(indicator_rect) + + # Legend: + text = "{} = {}".format(curve.name, curve_point_value) + text_rect = font_metrics.boundingRect(text) + # legend_y = y + index * text_height + legend_x = marker_x + 10 + legend_y = marker_y + text_x = legend_x + color_block_size + 3 - text_rect.x() + text_y = legend_y - text_rect.y() - text_rect.height() / 2 + self.painter.drawText(text_x, text_y, text) + self.painter.fillRect( + legend_x, + legend_y - color_block_size / 2, + color_block_size, + color_block_size, + color, + ) + def _draw_handles(self): x = self.layout.handles.left() diff --git a/python/lognplot/qt/render/render.py b/python/lognplot/qt/render/render.py index 944fabe..949dc30 100644 --- a/python/lognplot/qt/render/render.py +++ b/python/lognplot/qt/render/render.py @@ -15,14 +15,18 @@ class Renderer: Optionally include a minimap? """ - def __init__(self, painter: QtGui.QPainter, chart: Chart): + def __init__( + self, painter: QtGui.QPainter, chart: Chart, layout: ChartLayout, options + ): self.painter = painter self.chart = chart + self.layout = layout + self.options = options - def render(self, rect: QtCore.QRect): - options1 = ChartOptions() - layout = ChartLayout(rect, options1) - chart_renderer = ChartRenderer(self.painter, self.chart, layout, options1) + def render(self): + chart_renderer = ChartRenderer( + self.painter, self.chart, self.layout, self.options + ) chart_renderer.render() # self.render_minimap(rect) diff --git a/python/lognplot/qt/render/transform.py b/python/lognplot/qt/render/transform.py index c2fbebc..b0c86eb 100644 --- a/python/lognplot/qt/render/transform.py +++ b/python/lognplot/qt/render/transform.py @@ -17,3 +17,20 @@ def to_y_pixel(value, axis, layout): a = layout.chart_height / domain y = layout.chart_bottom - a * (value - axis.minimum) return clip(y, layout.chart_top, layout.chart_bottom) + + +def to_x_value(pixel, axis, layout): + """ Given a pixel, determine its domain value. """ + domain = axis.domain + a = domain / layout.chart_width + value = axis.minimum + a * (pixel - layout.chart_left) + return value + # return clip(x, layout.chart_left, layout.chart_right) + + +def x_pixels_to_domain(pixels, axis, layout): + """ Convert a pixel distance to a domain distance """ + domain = axis.domain + a = domain / layout.chart_width + shift = a * pixels + return shift diff --git a/python/lognplot/qt/widgets/basewidget.py b/python/lognplot/qt/widgets/basewidget.py index d50d867..9baca82 100644 --- a/python/lognplot/qt/widgets/basewidget.py +++ b/python/lognplot/qt/widgets/basewidget.py @@ -29,7 +29,9 @@ def mousePressEvent(self, event): def mouseMoveEvent(self, event): super().mouseMoveEvent(event) - self._update_mouse_pan(event.x(), event.y()) + x, y = event.x(), event.y() + self._update_mouse_pan(x, y) + self.mouse_move(x, y) def mouseReleaseEvent(self, event): super().mouseReleaseEvent(event) @@ -88,11 +90,11 @@ def pan_down(self): # Zooming helpers: ZOOM_FACTOR = 0.1 - def zoom_in_horizontal(self): - self.horizontal_zoom(-self.ZOOM_FACTOR) + def zoom_in_horizontal(self, around=None): + self.horizontal_zoom(-self.ZOOM_FACTOR, around) - def zoom_out_horizontal(self): - self.horizontal_zoom(self.ZOOM_FACTOR) + def zoom_out_horizontal(self, around=None): + self.horizontal_zoom(self.ZOOM_FACTOR, around) def zoom_in_vertical(self): self.vertical_zoom(self.ZOOM_FACTOR) @@ -101,7 +103,7 @@ def zoom_out_vertical(self): self.vertical_zoom(-self.ZOOM_FACTOR) # Overridable methods: - def horizontal_zoom(self, amount): + def horizontal_zoom(self, amount, around): pass def vertical_zoom(self, amount): diff --git a/python/lognplot/qt/widgets/chartwidget.py b/python/lognplot/qt/widgets/chartwidget.py index 20d660c..ff287ff 100644 --- a/python/lognplot/qt/widgets/chartwidget.py +++ b/python/lognplot/qt/widgets/chartwidget.py @@ -9,6 +9,7 @@ from ...utils import bench_it from ...chart import Chart, Curve from ..render import render_chart_on_qpainter, ChartLayout, ChartOptions +from ..render import transform from . import mime from .basewidget import BaseWidget @@ -24,8 +25,13 @@ class ChartWidget(BaseWidget): def __init__(self, db): super().__init__() self.chart = Chart(db) + self.chart_options = ChartOptions() + self.chart_layout = None # Set when resized + self._colors = cycle(color_wheel) + self.setMouseTracking(True) + # Accept drop of signal names self.setAcceptDrops(True) @@ -37,6 +43,9 @@ def __init__(self, db): self._tailing_timer.timeout.connect(self._on_tailing_timeout) self._tailing_timer.start(50) + def resizeEvent(self, event): + self.chart_layout = ChartLayout(self.rect(), self.chart_options) + # Drag drop events: def dragEnterEvent(self, event): if event.mimeData().hasFormat(mime.signal_names_mime_type): @@ -50,6 +59,27 @@ def dropEvent(self, event): self.logger.debug(f"Add curve {name}") self.add_curve(name) + # Mouse interactions: + def wheelEvent(self, event): + # print(event) + event.accept() + pos = event.pos() + x, y = pos.x(), pos.y() + value = transform.to_x_value(x, self.chart.x_axis, self.chart_layout) + delta = event.angleDelta().y() + if delta > 0: + self.zoom_in_horizontal(around=value) + elif delta < 0: + self.zoom_out_horizontal(around=value) + else: + pass + + def mouse_move(self, x, y): + # Update cursor! + value = transform.to_x_value(x, self.chart.x_axis, self.chart_layout) + self.chart.set_cursor(value) + self.update() + def curveHandleAtPoint(self, x, y) -> Curve: for curve in self.chart.curves: topleft = curve.handle[0] @@ -76,16 +106,15 @@ def mouseRelease(self, x, y): def mouseDrag(self, x, y, dx, dy): if self._drag_handle is not None: - self._drag_handle.axis.pan(dy / self.rect().height()) + self._drag_handle.axis.pan_relative(dy / self.rect().height()) self.repaint() - # Intended to work together with the WIP minimap? def pan(self, dx, dy): - print("pan", dx, dy) - # TODO: fix - #options1 = ChartOptions() - #layout = ChartLayout(self.rect(), options1) - #self.repaint() + # 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() + self.update() def add_curve(self, name, color=None): if not self.chart.has_curve(name): @@ -101,12 +130,14 @@ def paintEvent(self, e): # Contrapt graph via QPainter! painter = QtGui.QPainter(self) # with bench_it("render"): - render_chart_on_qpainter(self.chart, painter, self.rect()) + render_chart_on_qpainter( + self.chart, painter, self.chart_layout, self.chart_options + ) self.draw_focus_indicator(painter, self.rect()) - def horizontal_zoom(self, amount): - self.chart.horizontal_zoom(amount) + def horizontal_zoom(self, amount, around): + self.chart.horizontal_zoom(amount, around) # Autoscale Y for a nice effect? self.chart.autoscale_y() self.repaint() @@ -118,15 +149,14 @@ def vertical_zoom(self, amount): self.update() def horizontal_pan(self, amount): - self.chart.horizontal_pan(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): - self.chart.vertical_pan(amount) - self.repaint() + self.chart.vertical_pan_relative(amount) self.update() def zoom_fit(self): diff --git a/python/lognplot/qt/widgets/eventwidget.py b/python/lognplot/qt/widgets/eventwidget.py index fd8d333..714b033 100644 --- a/python/lognplot/qt/widgets/eventwidget.py +++ b/python/lognplot/qt/widgets/eventwidget.py @@ -47,11 +47,11 @@ def clear_curves(self): self.update() def horizontal_pan(self, amount): - self.event_tracks.x_axis.pan(amount) + self.event_tracks.x_axis.pan_relative(amount) self.update() - def horizontal_zoom(self, amount): - self.event_tracks.x_axis.zoom(amount) + def horizontal_zoom(self, amount, around): + self.event_tracks.x_axis.zoom(amount, around=around) self.update() def zoom_fit(self): diff --git a/python/lognplot/qt/widgets/logwidget.py b/python/lognplot/qt/widgets/logwidget.py index ada2964..c18b306 100644 --- a/python/lognplot/qt/widgets/logwidget.py +++ b/python/lognplot/qt/widgets/logwidget.py @@ -45,9 +45,9 @@ def clear_curves(self): self.update() def horizontal_pan(self, amount): - self.log_bar.x_axis.pan(amount) + self.log_bar.x_axis.pan_relative(amount) self.update() - def horizontal_zoom(self, amount): - self.log_bar.x_axis.zoom(amount) + def horizontal_zoom(self, amount, around): + self.log_bar.x_axis.zoom(amount, around=around) self.update() diff --git a/python/lognplot/time/timespan.py b/python/lognplot/time/timespan.py index 4b1cbbc..2a8039f 100644 --- a/python/lognplot/time/timespan.py +++ b/python/lognplot/time/timespan.py @@ -30,6 +30,7 @@ def overlaps(self, other): return (self.begin <= other.end) and (other.begin <= self.end) def contains_timestamp(self, timestamp): + """ Test if this timespan contains the given timestamp. """ return self.begin <= timestamp <= self.end def central_timestamp(self): diff --git a/python/lognplot/tsdb/btree.py b/python/lognplot/tsdb/btree.py index 0ffe54f..360d6fe 100644 --- a/python/lognplot/tsdb/btree.py +++ b/python/lognplot/tsdb/btree.py @@ -4,6 +4,7 @@ """ import abc +import bisect from .metrics import Metrics from .aggregation import Aggregation from ..time import TimeSpan @@ -101,6 +102,13 @@ def query_metrics(self, selection_timespan: TimeSpan) -> Aggregation: if selected_aggregations: return Aggregation.from_aggregations(selected_aggregations) + def query_value(self, timestamp): + """ Query value closest to the given timestamp. + + Return a timestamp value pair as an observation point. + """ + return self.root_node.query_value(timestamp) + def last_value(self): """ Get last item in the collection """ return self.root_node.last_value() @@ -149,6 +157,10 @@ def select_range(self, selection_span: TimeSpan): def select_all(self): raise NotImplementedError() + @abc.abstractmethod + def query_value(self, timestamp): + raise NotImplementedError() + @abc.abstractmethod def last_value(self): raise NotImplementedError() @@ -233,6 +245,18 @@ def select_range(self, selection_span: TimeSpan): def select_all(self): return self._children + def query_value(self, timestamp): + full_span = self.aggregation.timespan + if timestamp < full_span.begin: + child_node = self._children[0] + elif timestamp > full_span.end: + child_node = self._children[-1] + else: + for child_node in self._children: + if child_node.aggregation.timespan.contains_timestamp(timestamp): + break + return child_node.query_value(timestamp) + def last_value(self): return self._children[-1].last_value() @@ -308,5 +332,18 @@ def select_all(self): """ return self.samples + def query_value(self, timestamp): + if not self.samples: + return + + # Create a theoretical sample for insertion: + sample = (timestamp, 0) + + index = bisect.bisect_left(self.samples, sample) + if index >= len(self.samples): + index = len(self.samples) - 1 + # raise NotImplementedError() + return self.samples[index] + def last_value(self): return self.samples[-1] diff --git a/python/lognplot/tsdb/db.py b/python/lognplot/tsdb/db.py index 112014b..84caf77 100644 --- a/python/lognplot/tsdb/db.py +++ b/python/lognplot/tsdb/db.py @@ -60,6 +60,10 @@ def query(self, name: str, timespan: TimeSpan, count: int): serie = self.get_or_create_serie(name) return serie.query(timespan, count) + def query_value(self, name, timestamp): + serie = self.get_or_create_serie(name) + return serie.query_value(timestamp) + def last_value(self, name): """ Retrieve last value of a trace """ serie = self.get_or_create_serie(name) diff --git a/python/lognplot/tsdb/metrics.py b/python/lognplot/tsdb/metrics.py index 7ca9667..ef63c79 100644 --- a/python/lognplot/tsdb/metrics.py +++ b/python/lognplot/tsdb/metrics.py @@ -43,12 +43,17 @@ class ValueMetrics(Metrics): For example, we have the count of samples about which these metrics are a summary. Also, we have minimum and maximum values. + + Note that addition is not commutative, the chunks are ordered + in sequence. """ - def __init__(self, count, minimum, maximum, mean, m2): + def __init__(self, count, minimum, maximum, first, last, mean, m2): self.count = count self.minimum = minimum self.maximum = maximum + self.first = first # First observed value + self.last = last # Last observed value self._mean = mean # The M2 value is a handy value for calculating the # variance online. See welford method on wikipedia. @@ -58,7 +63,7 @@ def __init__(self, count, minimum, maximum, mean, m2): @classmethod def from_value(cls, value): """ Convert a single sample into metrics. """ - return cls(1, value, value, value, 0.0) + return cls(1, value, value, value, value, value, 0.0) def __add__(self, other): if isinstance(other, ValueMetrics): @@ -75,6 +80,8 @@ def __add__(self, other): count=count, minimum=min(self.minimum, other.minimum), maximum=max(self.maximum, other.maximum), + first=self.first, + last=other.last, mean=mean, m2=m2, ) diff --git a/python/lognplot/tsdb/series.py b/python/lognplot/tsdb/series.py index 1448bb5..3f4366d 100644 --- a/python/lognplot/tsdb/series.py +++ b/python/lognplot/tsdb/series.py @@ -72,5 +72,8 @@ def query_summary(self, selection_timespan=None) -> Aggregation: else: return self._tree.aggregation + def query_value(self, timestamp): + return self._tree.query_value(timestamp) + def last_value(self): return self._tree.last_value() diff --git a/python/setup.py b/python/setup.py index 891135c..5b78562 100644 --- a/python/setup.py +++ b/python/setup.py @@ -6,7 +6,7 @@ author="Windel Bouwman", description="Log and plot data. This project basically implements a software scope.", url="https://github.com/windelbouwman/lognplot", - install_requires=['cbor'], + install_requires=["cbor"], packages=find_packages(), license="GPLv3", classifiers=[ From a364b185f852c41b18eaf965ccd0888c7bb3381d Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Mon, 6 Jan 2020 00:34:28 +0100 Subject: [PATCH 8/9] Fix cursor position on each signal's axis. --- python/lognplot/qt/render/chart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/lognplot/qt/render/chart.py b/python/lognplot/qt/render/chart.py index a5a2394..ea15a5d 100644 --- a/python/lognplot/qt/render/chart.py +++ b/python/lognplot/qt/render/chart.py @@ -249,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(self.chart.y_axis, 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, From d2b5dece87219a6533625331cf1a1fe98cc7007f Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Mon, 6 Jan 2020 00:43:42 +0100 Subject: [PATCH 9/9] Fix panning feature due to upstream changes. --- python/lognplot/qt/render/options.py | 1 + python/lognplot/qt/widgets/chartwidget.py | 22 ++++++++-------------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/python/lognplot/qt/render/options.py b/python/lognplot/qt/render/options.py index dc20b2c..6bf6e33 100644 --- a/python/lognplot/qt/render/options.py +++ b/python/lognplot/qt/render/options.py @@ -4,6 +4,7 @@ def __init__(self): 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/chartwidget.py b/python/lognplot/qt/widgets/chartwidget.py index ff287ff..dbfde22 100644 --- a/python/lognplot/qt/widgets/chartwidget.py +++ b/python/lognplot/qt/widgets/chartwidget.py @@ -95,25 +95,19 @@ def curveHandleAtPoint(self, x, y) -> Curve: # Mouse interactions: def mousePress(self, x, y): - if self._drag_handle is None: - curve = self.curveHandleAtPoint(x,y) - if curve is not None: - self._drag_handle = curve - self.chart.change_active_curve(curve) - - def mouseRelease(self, x, y): - self._drag_handle = None - - def mouseDrag(self, x, y, dx, dy): - if self._drag_handle is not None: - self._drag_handle.axis.pan_relative(dy / self.rect().height()) - self.repaint() + 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):