From 75375feb0e85dfe737fff8beae4f56bb0852e518 Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Sat, 4 Jan 2020 22:39:04 +0100 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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): From 81e6caba3616ec19509a1a0ee09deb4877b09be2 Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Mon, 6 Jan 2020 23:25:15 +0100 Subject: [PATCH 10/15] Polished legend bar. --- python/lognplot/chart/curve.py | 2 + python/lognplot/qt/render/chart.py | 91 +++++++++++++++++++---- python/lognplot/qt/render/layout.py | 33 +++++--- python/lognplot/qt/render/options.py | 5 +- python/lognplot/qt/widgets/chartwidget.py | 24 +++++- 5 files changed, 123 insertions(+), 32 deletions(-) diff --git a/python/lognplot/chart/curve.py b/python/lognplot/chart/curve.py index cb8c96e..4ed7bb2 100644 --- a/python/lognplot/chart/curve.py +++ b/python/lognplot/chart/curve.py @@ -18,6 +18,8 @@ def __init__(self, db, name, color): self.average = 0 # Corresponding handle (polygon area) self.handle = [] + # Corresponding bar segment (polygon area) + self.bar_segment = [] # Each curve has its own vertical axis self.axis = Axis() diff --git a/python/lognplot/qt/render/chart.py b/python/lognplot/qt/render/chart.py index ea15a5d..af34095 100644 --- a/python/lognplot/qt/render/chart.py +++ b/python/lognplot/qt/render/chart.py @@ -33,6 +33,9 @@ def render(self): self.draw_x_axis(x_ticks) self.draw_y_axis(self.chart.y_axis, y_ticks) + if self.options.show_bar: + self._draw_bar() + if self.options.show_handles: self._draw_handles() @@ -260,21 +263,22 @@ def _draw_cursor(self): 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, - ) + if self.options.show_cursor_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() @@ -301,6 +305,63 @@ def _draw_handles(self): color = QtGui.QColor(curve.color) self.painter.fillPath(polygon, QtGui.QBrush(color)) + def _draw_bar(self): + bar = self.layout.bar + curves = self.chart.curves + font_metrics = self.painter.fontMetrics() + + segment_width = bar.width() / len(curves) + + x = bar.left() + for curve in curves: + indicator = QtGui.QPainterPath(QtCore.QPointF(x, bar.top())) + indicator.lineTo(QtCore.QPointF(x + bar.height(), bar.top())) + indicator.lineTo(QtCore.QPointF(x + bar.height(), bar.bottom())) + indicator.lineTo(QtCore.QPointF(x, bar.bottom())) + + self.painter.fillPath(indicator, QtGui.QBrush(QtGui.QColor(curve.color))) + + if curve != self.chart.activeCurve: + legend = QtGui.QPainterPath(QtCore.QPointF(x + bar.height(), bar.top())) + legend.lineTo(QtCore.QPointF(x + segment_width, bar.top())) + legend.lineTo(QtCore.QPointF(x + segment_width, bar.bottom())) + legend.lineTo(QtCore.QPointF(x + bar.height(), bar.bottom())) + self.painter.fillPath(legend, QtGui.QBrush(Qt.lightGray)) + + curve.bar_segment = [ + QtCore.QPointF(x, bar.top()), + QtCore.QPointF(x + segment_width, bar.top()), + QtCore.QPointF(x + segment_width, bar.bottom()), + QtCore.QPointF(x, bar.bottom()), + ] + + polygon = QtGui.QPainterPath(curve.bar_segment[0]) + for p in curve.bar_segment[1:]: + polygon.lineTo(p) + polygon.lineTo(curve.bar_segment[0]) + polygon.lineTo(QtCore.QPointF(x + bar.height(), bar.top())) + polygon.lineTo(QtCore.QPointF(x + bar.height(), bar.bottom())) + + pen = QtGui.QPen(Qt.black) + pen.setWidth(2) + self.painter.strokePath(polygon, pen) + + # Legend: + if self.chart.cursor: + curve_point = curve.query_value(self.chart.cursor) + if not curve_point: + continue + _, curve_point_value = curve_point + + text = format(curve_point_value, '.08g') + text_rect = font_metrics.boundingRect(text) + # legend_y = y + index * text_height + text_x = curve.bar_segment[0].x() + bar.height() + 3 - text_rect.x() + text_y = curve.bar_segment[0].y() + bar.height() / 2 - text_rect.y() - text_rect.height() / 2 + self.painter.drawText(text_x, text_y, text) + + x += segment_width + 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 1f0a84f..f629daa 100644 --- a/python/lognplot/qt/render/layout.py +++ b/python/lognplot/qt/render/layout.py @@ -12,24 +12,12 @@ 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() def do_layout(self): # self.right = self.rect.right() # self.bottom = self.rect.bottom() - self.chart_top = self.rect.top() + 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 @@ -37,6 +25,27 @@ def do_layout(self): axis_height = 0 axis_width = 0 + if self.options.show_bar: + self.bar = QtCore.QRect(self.rect.left() + self.options.padding, + self.rect.top() + self.options.padding, + self.rect.right() - 2 * self.options.padding, + self.options.bar_height) + + self.chart_top = self.bar.bottom() + 5 + else: + self.chart_top = self.rect.top() + self.options.padding + + self.handles = QtCore.QRect(self.rect.left() + self.options.padding, + self.rect.top(), + self.options.handle_width, + self.rect.height()) + + if self.options.show_handles: + self.chart_left = self.handles.right() + 3 + else: + self.chart_left = self.rect.left() + self.options.padding + + self.chart_bottom = self.rect.bottom() - self.options.padding - axis_height self.chart_right = self.rect.right() - self.options.padding - axis_width self.chart_width = self.chart_right - self.chart_left diff --git a/python/lognplot/qt/render/options.py b/python/lognplot/qt/render/options.py index 6bf6e33..31ee639 100644 --- a/python/lognplot/qt/render/options.py +++ b/python/lognplot/qt/render/options.py @@ -4,7 +4,10 @@ def __init__(self): self.show_grid = True self.show_legend = False self.show_handles = True + self.show_bar = True self.autoscale_y_axis = False + self.show_cursor_legend = False self.padding = 10 self.handle_width = 20 - self.handle_height = 15 \ No newline at end of file + self.handle_height = 15 + self.bar_height = 25 \ No newline at end of file diff --git a/python/lognplot/qt/widgets/chartwidget.py b/python/lognplot/qt/widgets/chartwidget.py index 1c56b63..451dbc9 100644 --- a/python/lognplot/qt/widgets/chartwidget.py +++ b/python/lognplot/qt/widgets/chartwidget.py @@ -91,11 +91,25 @@ def curveHandleAtPoint(self, x, y) -> Curve: return curve return None + def barHandleAtPoint(self, x, y) -> Curve: + for curve in self.chart.curves: + topleft = curve.bar_segment[0] + topright = curve.bar_segment[1] + bottomleft = curve.bar_segment[-1] + if (x >= topleft.x() and + x <= topright.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 None: + curve = self.barHandleAtPoint(x,y) if curve is not None: - self._drag_handle = curve self.chart.change_active_curve(curve) def pan(self, dx, dy): @@ -105,7 +119,7 @@ def pan(self, dx, dy): if self.chart_options.autoscale_y_axis: self.chart.autoscale_y() else: - self._drag_handle.axis.pan_relative(dy / self.rect().height()) + self.chart.activeCurve.axis.pan_relative(dy / self.rect().height()) self.update() def add_curve(self, name, color=None): @@ -131,7 +145,8 @@ def paintEvent(self, e): def horizontal_zoom(self, amount, around): self.chart.horizontal_zoom(amount, around) # Autoscale Y for a nice effect? - self.chart.autoscale_y() + if self.chart_options.autoscale_y_axis: + self.chart.autoscale_y() self.repaint() self.update() @@ -143,7 +158,8 @@ def vertical_zoom(self, amount): def horizontal_pan(self, amount): self.chart.horizontal_pan_relative(amount) # Autoscale Y for a nice effect? - self.chart.autoscale_y() + if self.chart_options.autoscale_y_axis: + self.chart.autoscale_y() self.repaint() self.update() From ffe77923763fd787efc9bd257eb12fab18ab9c1c Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Mon, 6 Jan 2020 23:25:30 +0100 Subject: [PATCH 11/15] Anchored signal handles on the left to zero instead of plot average. --- python/lognplot/chart/curve.py | 2 -- python/lognplot/qt/render/chart.py | 10 +++------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/python/lognplot/chart/curve.py b/python/lognplot/chart/curve.py index 4ed7bb2..7ecad2f 100644 --- a/python/lognplot/chart/curve.py +++ b/python/lognplot/chart/curve.py @@ -14,8 +14,6 @@ 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 = [] # Corresponding bar segment (polygon area) diff --git a/python/lognplot/qt/render/chart.py b/python/lognplot/qt/render/chart.py index af34095..facc3cf 100644 --- a/python/lognplot/qt/render/chart.py +++ b/python/lognplot/qt/render/chart.py @@ -85,9 +85,9 @@ def _draw_curve(self, curve): if data: if isinstance(data[0], Aggregation): - curve.average = self._draw_aggregations_as_shape(curve.axis, data, curve_color) + self._draw_aggregations_as_shape(curve.axis, data, curve_color) else: - curve.average = self._draw_samples_as_lines(curve.axis, data, curve_color) + self._draw_samples_as_lines(curve.axis, data, curve_color) def _draw_samples_as_lines(self, y_axis: Axis, samples, curve_color: QtGui.QColor): """ Draw raw samples as lines! """ @@ -105,8 +105,6 @@ def _draw_samples_as_lines(self, y_axis: Axis, samples, curve_color: QtGui.QColo 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, y_axis: Axis, aggregations: Aggregation, curve_color: QtGui.QColor ): @@ -198,8 +196,6 @@ 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. """ @@ -284,7 +280,7 @@ def _draw_handles(self): x = self.layout.handles.left() for _, curve in enumerate(self.chart.curves): - handle_y = curve.average + handle_y = self.to_y_pixel(curve.axis, 0) x_full = self.options.handle_width x_half = x_full / 2 y_half = self.options.handle_height / 2 From 7724aa4feb6d53738bf90eaa3979802802262e41 Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Mon, 6 Jan 2020 23:51:44 +0100 Subject: [PATCH 12/15] Made the legend a dedicated render entity. --- python/lognplot/chart/__init__.py | 1 + python/lognplot/chart/chart.py | 2 + python/lognplot/chart/curve.py | 4 +- python/lognplot/chart/legend.py | 4 ++ python/lognplot/qt/render/chart.py | 87 ----------------------- python/lognplot/qt/render/layout.py | 12 ++-- python/lognplot/qt/render/legend.py | 77 ++++++++++++++++++++ python/lognplot/qt/render/options.py | 5 +- python/lognplot/qt/render/render.py | 6 ++ python/lognplot/qt/widgets/chartwidget.py | 10 +-- 10 files changed, 105 insertions(+), 103 deletions(-) create mode 100644 python/lognplot/chart/legend.py create mode 100644 python/lognplot/qt/render/legend.py diff --git a/python/lognplot/chart/__init__.py b/python/lognplot/chart/__init__.py index 9400f93..528e696 100644 --- a/python/lognplot/chart/__init__.py +++ b/python/lognplot/chart/__init__.py @@ -1,5 +1,6 @@ from .axis import Axis from .chart import Chart from .curve import Curve +from .legend import Legend from .logbar import LogTrack, LogBar from .event_tracks import EventTracks diff --git a/python/lognplot/chart/chart.py b/python/lognplot/chart/chart.py index e7cb4e7..de7237a 100644 --- a/python/lognplot/chart/chart.py +++ b/python/lognplot/chart/chart.py @@ -1,6 +1,7 @@ import math from .axis import Axis from .curve import Curve +from .legend import Legend from ..utils import bench_it from ..time import TimeSpan from ..tsdb import Aggregation, Metrics @@ -17,6 +18,7 @@ class Chart: def __init__(self, db): self.x_axis = Axis() self.y_axis = Axis() + self.legend = Legend() self.curves = [] self.activeCurve = None self.cursor = None diff --git a/python/lognplot/chart/curve.py b/python/lognplot/chart/curve.py index 7ecad2f..d65dfb9 100644 --- a/python/lognplot/chart/curve.py +++ b/python/lognplot/chart/curve.py @@ -16,8 +16,8 @@ def __init__(self, db, name, color): self.color = color # Corresponding handle (polygon area) self.handle = [] - # Corresponding bar segment (polygon area) - self.bar_segment = [] + # Corresponding legend segment (polygon area) + self.legend_segment = [] # Each curve has its own vertical axis self.axis = Axis() diff --git a/python/lognplot/chart/legend.py b/python/lognplot/chart/legend.py new file mode 100644 index 0000000..2085d33 --- /dev/null +++ b/python/lognplot/chart/legend.py @@ -0,0 +1,4 @@ +class Legend: + + def __init__(self): + pass \ No newline at end of file diff --git a/python/lognplot/qt/render/chart.py b/python/lognplot/qt/render/chart.py index facc3cf..a91d911 100644 --- a/python/lognplot/qt/render/chart.py +++ b/python/lognplot/qt/render/chart.py @@ -33,16 +33,11 @@ def render(self): self.draw_x_axis(x_ticks) self.draw_y_axis(self.chart.y_axis, y_ticks) - if self.options.show_bar: - self._draw_bar() - if self.options.show_handles: self._draw_handles() self._draw_curves() - if self.options.show_legend: - self._draw_legend() self._draw_cursor() def shade_region(self, region): @@ -196,31 +191,6 @@ def _draw_aggregations_as_shape( min_line = QtGui.QPolygon(min_points) self.painter.drawPolyline(min_line) - def _draw_legend(self): - """ Draw names / color of the curve next to eachother. - """ - font_metrics = self.painter.fontMetrics() - x = self.layout.chart_left + 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) - text = curve.name - text_rect = font_metrics.boundingRect(text) - legend_x = x - legend_y = y + index * text_height - 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( - x, - legend_y - color_block_size / 2, - color_block_size, - color_block_size, - color, - ) - def _draw_cursor(self): if self.chart.cursor: # Draw cursor line: @@ -301,63 +271,6 @@ def _draw_handles(self): color = QtGui.QColor(curve.color) self.painter.fillPath(polygon, QtGui.QBrush(color)) - def _draw_bar(self): - bar = self.layout.bar - curves = self.chart.curves - font_metrics = self.painter.fontMetrics() - - segment_width = bar.width() / len(curves) - - x = bar.left() - for curve in curves: - indicator = QtGui.QPainterPath(QtCore.QPointF(x, bar.top())) - indicator.lineTo(QtCore.QPointF(x + bar.height(), bar.top())) - indicator.lineTo(QtCore.QPointF(x + bar.height(), bar.bottom())) - indicator.lineTo(QtCore.QPointF(x, bar.bottom())) - - self.painter.fillPath(indicator, QtGui.QBrush(QtGui.QColor(curve.color))) - - if curve != self.chart.activeCurve: - legend = QtGui.QPainterPath(QtCore.QPointF(x + bar.height(), bar.top())) - legend.lineTo(QtCore.QPointF(x + segment_width, bar.top())) - legend.lineTo(QtCore.QPointF(x + segment_width, bar.bottom())) - legend.lineTo(QtCore.QPointF(x + bar.height(), bar.bottom())) - self.painter.fillPath(legend, QtGui.QBrush(Qt.lightGray)) - - curve.bar_segment = [ - QtCore.QPointF(x, bar.top()), - QtCore.QPointF(x + segment_width, bar.top()), - QtCore.QPointF(x + segment_width, bar.bottom()), - QtCore.QPointF(x, bar.bottom()), - ] - - polygon = QtGui.QPainterPath(curve.bar_segment[0]) - for p in curve.bar_segment[1:]: - polygon.lineTo(p) - polygon.lineTo(curve.bar_segment[0]) - polygon.lineTo(QtCore.QPointF(x + bar.height(), bar.top())) - polygon.lineTo(QtCore.QPointF(x + bar.height(), bar.bottom())) - - pen = QtGui.QPen(Qt.black) - pen.setWidth(2) - self.painter.strokePath(polygon, pen) - - # Legend: - if self.chart.cursor: - curve_point = curve.query_value(self.chart.cursor) - if not curve_point: - continue - _, curve_point_value = curve_point - - text = format(curve_point_value, '.08g') - text_rect = font_metrics.boundingRect(text) - # legend_y = y + index * text_height - text_x = curve.bar_segment[0].x() + bar.height() + 3 - text_rect.x() - text_y = curve.bar_segment[0].y() + bar.height() / 2 - text_rect.y() - text_rect.height() / 2 - self.painter.drawText(text_x, text_y, text) - - x += segment_width - 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 f629daa..6b92a84 100644 --- a/python/lognplot/qt/render/layout.py +++ b/python/lognplot/qt/render/layout.py @@ -25,13 +25,13 @@ def do_layout(self): axis_height = 0 axis_width = 0 - if self.options.show_bar: - self.bar = QtCore.QRect(self.rect.left() + self.options.padding, - self.rect.top() + self.options.padding, - self.rect.right() - 2 * self.options.padding, - self.options.bar_height) + if self.options.show_legend: + self.legend = QtCore.QRect(self.rect.left() + self.options.padding, + self.rect.top() + self.options.padding, + self.rect.right() - 2 * self.options.padding, + self.options.legend_height) - self.chart_top = self.bar.bottom() + 5 + self.chart_top = self.legend.bottom() + 5 else: self.chart_top = self.rect.top() + self.options.padding diff --git a/python/lognplot/qt/render/legend.py b/python/lognplot/qt/render/legend.py new file mode 100644 index 0000000..9b71551 --- /dev/null +++ b/python/lognplot/qt/render/legend.py @@ -0,0 +1,77 @@ +from ..qtapi import QtGui, QtCore, Qt +from ...chart import Axis, Chart +from ...tsdb import Aggregation +from .layout import ChartLayout +from .base import BaseRenderer + +class LegendRenderer(BaseRenderer): + + def __init__( + self, painter: QtGui.QPainter, chart: Chart, layout: ChartLayout, options + ): + super().__init__(painter, layout) + self.chart = chart + self.options = options + + def render(self): + if self.options.show_legend: + self._draw_legend() + + def _draw_legend(self): + legend = self.layout.legend + curves = self.chart.curves + font_metrics = self.painter.fontMetrics() + + segment_width = legend.width() / len(curves) + + x = legend.left() + for curve in curves: + indicator = QtGui.QPainterPath(QtCore.QPointF(x, legend.top())) + indicator.lineTo(QtCore.QPointF(x + legend.height(), legend.top())) + indicator.lineTo(QtCore.QPointF(x + legend.height(), legend.bottom())) + indicator.lineTo(QtCore.QPointF(x, legend.bottom())) + + self.painter.fillPath(indicator, QtGui.QBrush(QtGui.QColor(curve.color))) + + if curve != self.chart.activeCurve: + labelArea = QtGui.QPainterPath(QtCore.QPointF(x + legend.height(), legend.top())) + labelArea.lineTo(QtCore.QPointF(x + segment_width, legend.top())) + labelArea.lineTo(QtCore.QPointF(x + segment_width, legend.bottom())) + labelArea.lineTo(QtCore.QPointF(x + legend.height(), legend.bottom())) + self.painter.fillPath(labelArea, QtGui.QBrush(Qt.lightGray)) + + curve.legend_segment = [ + QtCore.QPointF(x, legend.top()), + QtCore.QPointF(x + segment_width, legend.top()), + QtCore.QPointF(x + segment_width, legend.bottom()), + QtCore.QPointF(x, legend.bottom()), + ] + + polygon = QtGui.QPainterPath(curve.legend_segment[0]) + for p in curve.legend_segment[1:]: + polygon.lineTo(p) + polygon.lineTo(curve.legend_segment[0]) + polygon.lineTo(QtCore.QPointF(x + legend.height(), legend.top())) + polygon.lineTo(QtCore.QPointF(x + legend.height(), legend.bottom())) + + pen = QtGui.QPen(Qt.black) + pen.setWidth(2) + self.painter.strokePath(polygon, pen) + + # Legend: + if self.chart.cursor: + curve_point = curve.query_value(self.chart.cursor) + if not curve_point: + continue + _, curve_point_value = curve_point + + text = format(curve_point_value, '.08g') + text_rect = font_metrics.boundingRect(text) + # legend_y = y + index * text_height + text_x = curve.legend_segment[0].x() + legend.height() + 3 - text_rect.x() + text_y = curve.legend_segment[0].y() + legend.height() / 2 - text_rect.y() - text_rect.height() / 2 + self.painter.setPen(Qt.black) + self.painter.drawText(text_x, text_y, text) + + x += segment_width + diff --git a/python/lognplot/qt/render/options.py b/python/lognplot/qt/render/options.py index 31ee639..82c5a72 100644 --- a/python/lognplot/qt/render/options.py +++ b/python/lognplot/qt/render/options.py @@ -2,12 +2,11 @@ class ChartOptions: def __init__(self): self.show_axis = True self.show_grid = True - self.show_legend = False + self.show_legend = True self.show_handles = True - self.show_bar = True self.autoscale_y_axis = False self.show_cursor_legend = False self.padding = 10 self.handle_width = 20 self.handle_height = 15 - self.bar_height = 25 \ No newline at end of file + self.legend_height = 25 \ No newline at end of file diff --git a/python/lognplot/qt/render/render.py b/python/lognplot/qt/render/render.py index 949dc30..11b81d0 100644 --- a/python/lognplot/qt/render/render.py +++ b/python/lognplot/qt/render/render.py @@ -6,6 +6,7 @@ from ...tsdb.metrics import Metrics from .layout import ChartLayout from .chart import ChartRenderer +from .legend import LegendRenderer from .options import ChartOptions @@ -29,6 +30,11 @@ def render(self): ) chart_renderer.render() + legend_renderer = LegendRenderer( + self.painter, self.chart, self.layout, self.options + ) + legend_renderer.render() + # self.render_minimap(rect) def render_minimap(self, rect: QtCore.QRect): diff --git a/python/lognplot/qt/widgets/chartwidget.py b/python/lognplot/qt/widgets/chartwidget.py index 451dbc9..3675e69 100644 --- a/python/lognplot/qt/widgets/chartwidget.py +++ b/python/lognplot/qt/widgets/chartwidget.py @@ -91,11 +91,11 @@ def curveHandleAtPoint(self, x, y) -> Curve: return curve return None - def barHandleAtPoint(self, x, y) -> Curve: + def legendSegmentAtPoint(self, x, y) -> Curve: for curve in self.chart.curves: - topleft = curve.bar_segment[0] - topright = curve.bar_segment[1] - bottomleft = curve.bar_segment[-1] + topleft = curve.legend_segment[0] + topright = curve.legend_segment[1] + bottomleft = curve.legend_segment[-1] if (x >= topleft.x() and x <= topright.x() and y >= topleft.y() and @@ -108,7 +108,7 @@ def barHandleAtPoint(self, x, y) -> Curve: def mousePress(self, x, y): curve = self.curveHandleAtPoint(x,y) if curve is None: - curve = self.barHandleAtPoint(x,y) + curve = self.legendSegmentAtPoint(x,y) if curve is not None: self.chart.change_active_curve(curve) From e8f6f93407d7aab12dc6d94aac54c4fb51aa8433 Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Tue, 7 Jan 2020 00:31:26 +0100 Subject: [PATCH 13/15] Cycle through multiple (currently 2) legend modes upon clicking active signal. --- python/lognplot/chart/__init__.py | 2 +- python/lognplot/chart/legend.py | 16 +++++- python/lognplot/qt/render/legend.py | 60 ++++++++++++++++------- python/lognplot/qt/widgets/chartwidget.py | 5 +- 4 files changed, 61 insertions(+), 22 deletions(-) diff --git a/python/lognplot/chart/__init__.py b/python/lognplot/chart/__init__.py index 528e696..f697eb7 100644 --- a/python/lognplot/chart/__init__.py +++ b/python/lognplot/chart/__init__.py @@ -1,6 +1,6 @@ from .axis import Axis from .chart import Chart from .curve import Curve -from .legend import Legend +from .legend import Legend, LegendMode from .logbar import LogTrack, LogBar from .event_tracks import EventTracks diff --git a/python/lognplot/chart/legend.py b/python/lognplot/chart/legend.py index 2085d33..af8d5d1 100644 --- a/python/lognplot/chart/legend.py +++ b/python/lognplot/chart/legend.py @@ -1,4 +1,18 @@ +from enum import Enum + +class LegendMode(Enum): + SIGNAL_NAMES = 0 + CURSOR_VALUES = 1 + class Legend: def __init__(self): - pass \ No newline at end of file + self.modes = [LegendMode.SIGNAL_NAMES, LegendMode.CURSOR_VALUES] + self.active_mode = 0 + + def next_mode(self): + self.active_mode = (self.active_mode + 1) % len(self.modes) + + @property + def mode(self) -> LegendMode: + return self.modes[self.active_mode] \ No newline at end of file diff --git a/python/lognplot/qt/render/legend.py b/python/lognplot/qt/render/legend.py index 9b71551..2d589e5 100644 --- a/python/lognplot/qt/render/legend.py +++ b/python/lognplot/qt/render/legend.py @@ -1,10 +1,13 @@ from ..qtapi import QtGui, QtCore, Qt -from ...chart import Axis, Chart +from ...chart import Axis, Chart, LegendMode, Curve from ...tsdb import Aggregation from .layout import ChartLayout from .base import BaseRenderer class LegendRenderer(BaseRenderer): + """ Not sure this should derive from BaseRenderer, + or BaseRenderer is doing way too much... + """ def __init__( self, painter: QtGui.QPainter, chart: Chart, layout: ChartLayout, options @@ -20,8 +23,7 @@ def render(self): def _draw_legend(self): legend = self.layout.legend curves = self.chart.curves - font_metrics = self.painter.fontMetrics() - + segment_width = legend.width() / len(curves) x = legend.left() @@ -57,21 +59,41 @@ def _draw_legend(self): pen = QtGui.QPen(Qt.black) pen.setWidth(2) self.painter.strokePath(polygon, pen) - - # Legend: - if self.chart.cursor: - curve_point = curve.query_value(self.chart.cursor) - if not curve_point: - continue - _, curve_point_value = curve_point - - text = format(curve_point_value, '.08g') - text_rect = font_metrics.boundingRect(text) - # legend_y = y + index * text_height - text_x = curve.legend_segment[0].x() + legend.height() + 3 - text_rect.x() - text_y = curve.legend_segment[0].y() + legend.height() / 2 - text_rect.y() - text_rect.height() / 2 - self.painter.setPen(Qt.black) - self.painter.drawText(text_x, text_y, text) - + + if self.chart.legend.mode == LegendMode.SIGNAL_NAMES: + self._draw_signal_names(x, curve) + elif self.chart.legend.mode == LegendMode.CURSOR_VALUES: + self._draw_cursor_values(x, curve) + x += segment_width + def _draw_cursor_values(self, x, curve: Curve): + if self.chart.cursor: + curve_point = curve.query_value(self.chart.cursor) + if not curve_point: + return + _, curve_point_value = curve_point + + legend = self.layout.legend + font_metrics = self.painter.fontMetrics() + + text = format(curve_point_value, '.08g') + text_rect = font_metrics.boundingRect(text) + # legend_y = y + index * text_height + text_x = curve.legend_segment[0].x() + legend.height() + 3 - text_rect.x() + text_y = curve.legend_segment[0].y() + legend.height() / 2 - text_rect.y() - text_rect.height() / 2 + self.painter.setPen(Qt.black) + self.painter.drawText(text_x, text_y, text) + + def _draw_signal_names(self, x, curve: Curve): + legend = self.layout.legend + font_metrics = self.painter.fontMetrics() + + text = curve.name + text_rect = font_metrics.boundingRect(text) + # legend_y = y + index * text_height + text_x = curve.legend_segment[0].x() + legend.height() + 3 - text_rect.x() + text_y = curve.legend_segment[0].y() + legend.height() / 2 - text_rect.y() - text_rect.height() / 2 + self.painter.setPen(Qt.black) + self.painter.drawText(text_x, text_y, text) + diff --git a/python/lognplot/qt/widgets/chartwidget.py b/python/lognplot/qt/widgets/chartwidget.py index 3675e69..1d0b494 100644 --- a/python/lognplot/qt/widgets/chartwidget.py +++ b/python/lognplot/qt/widgets/chartwidget.py @@ -109,8 +109,11 @@ def mousePress(self, x, y): curve = self.curveHandleAtPoint(x,y) if curve is None: curve = self.legendSegmentAtPoint(x,y) + if curve == self.chart.activeCurve: + self.chart.legend.next_mode() if curve is not None: - self.chart.change_active_curve(curve) + if curve != self.chart.activeCurve: + self.chart.change_active_curve(curve) def pan(self, dx, dy): # print("pan", dx, dy) From e2e0146f8ea4d98e937c705d6d8cc935b3e68060 Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Tue, 7 Jan 2020 00:46:31 +0100 Subject: [PATCH 14/15] Added legend mode showing value per div. --- python/lognplot/chart/legend.py | 5 +++- python/lognplot/qt/render/legend.py | 36 ++++++++++++++--------------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/python/lognplot/chart/legend.py b/python/lognplot/chart/legend.py index af8d5d1..52d42f9 100644 --- a/python/lognplot/chart/legend.py +++ b/python/lognplot/chart/legend.py @@ -3,11 +3,14 @@ class LegendMode(Enum): SIGNAL_NAMES = 0 CURSOR_VALUES = 1 + Y_AXIS_SCALE = 2 class Legend: def __init__(self): - self.modes = [LegendMode.SIGNAL_NAMES, LegendMode.CURSOR_VALUES] + self.modes = [LegendMode.SIGNAL_NAMES, + LegendMode.CURSOR_VALUES, + LegendMode.Y_AXIS_SCALE] self.active_mode = 0 def next_mode(self): diff --git a/python/lognplot/qt/render/legend.py b/python/lognplot/qt/render/legend.py index 2d589e5..884f80a 100644 --- a/python/lognplot/qt/render/legend.py +++ b/python/lognplot/qt/render/legend.py @@ -64,9 +64,21 @@ def _draw_legend(self): self._draw_signal_names(x, curve) elif self.chart.legend.mode == LegendMode.CURSOR_VALUES: self._draw_cursor_values(x, curve) + elif self.chart.legend.mode == LegendMode.Y_AXIS_SCALE: + self._draw_y_axis_scale(x, curve) x += segment_width + def _draw_text(self, x, curve: Curve, text): + legend = self.layout.legend + font_metrics = self.painter.fontMetrics() + + text_rect = font_metrics.boundingRect(text) + text_x = curve.legend_segment[0].x() + legend.height() + 3 - text_rect.x() + text_y = curve.legend_segment[0].y() + legend.height() / 2 - text_rect.y() - text_rect.height() / 2 + self.painter.setPen(Qt.black) + self.painter.drawText(text_x, text_y, text) + def _draw_cursor_values(self, x, curve: Curve): if self.chart.cursor: curve_point = curve.query_value(self.chart.cursor) @@ -74,26 +86,14 @@ def _draw_cursor_values(self, x, curve: Curve): return _, curve_point_value = curve_point - legend = self.layout.legend - font_metrics = self.painter.fontMetrics() - text = format(curve_point_value, '.08g') - text_rect = font_metrics.boundingRect(text) - # legend_y = y + index * text_height - text_x = curve.legend_segment[0].x() + legend.height() + 3 - text_rect.x() - text_y = curve.legend_segment[0].y() + legend.height() / 2 - text_rect.y() - text_rect.height() / 2 - self.painter.setPen(Qt.black) - self.painter.drawText(text_x, text_y, text) + self._draw_text(x, curve, text) def _draw_signal_names(self, x, curve: Curve): - legend = self.layout.legend - font_metrics = self.painter.fontMetrics() + self._draw_text(x, curve, curve.name) - text = curve.name - text_rect = font_metrics.boundingRect(text) - # legend_y = y + index * text_height - text_x = curve.legend_segment[0].x() + legend.height() + 3 - text_rect.x() - text_y = curve.legend_segment[0].y() + legend.height() / 2 - text_rect.y() - text_rect.height() / 2 - self.painter.setPen(Qt.black) - self.painter.drawText(text_x, text_y, text) + def _draw_y_axis_scale(self, x, curve: Curve): + ticks = self.calc_y_ticks(curve.axis) + valperdiv = ticks[1][0] - ticks[0][0] + self._draw_text(x, curve, '{} / div'.format(valperdiv)) From 3645f5a4bfbbb331b88ed473924f4b07e1b9eb26 Mon Sep 17 00:00:00 2001 From: Timon ter Braak Date: Tue, 7 Jan 2020 01:00:44 +0100 Subject: [PATCH 15/15] Minor visual polishing. --- python/lognplot/qt/render/layout.py | 2 +- python/lognplot/qt/render/legend.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/lognplot/qt/render/layout.py b/python/lognplot/qt/render/layout.py index 6b92a84..3c1b21e 100644 --- a/python/lognplot/qt/render/layout.py +++ b/python/lognplot/qt/render/layout.py @@ -31,7 +31,7 @@ def do_layout(self): self.rect.right() - 2 * self.options.padding, self.options.legend_height) - self.chart_top = self.legend.bottom() + 5 + self.chart_top = self.legend.bottom() + 10 else: self.chart_top = self.rect.top() + self.options.padding diff --git a/python/lognplot/qt/render/legend.py b/python/lognplot/qt/render/legend.py index 884f80a..c6af6aa 100644 --- a/python/lognplot/qt/render/legend.py +++ b/python/lognplot/qt/render/legend.py @@ -74,7 +74,7 @@ def _draw_text(self, x, curve: Curve, text): font_metrics = self.painter.fontMetrics() text_rect = font_metrics.boundingRect(text) - text_x = curve.legend_segment[0].x() + legend.height() + 3 - text_rect.x() + text_x = curve.legend_segment[0].x() + legend.height() + 5 - text_rect.x() text_y = curve.legend_segment[0].y() + legend.height() / 2 - text_rect.y() - text_rect.height() / 2 self.painter.setPen(Qt.black) self.painter.drawText(text_x, text_y, text)