diff --git a/python/lognplot/chart/__init__.py b/python/lognplot/chart/__init__.py index 9400f93..f697eb7 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, LegendMode 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 59e499f..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,7 +18,9 @@ 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 self.db = db @@ -31,9 +34,15 @@ def add_curve(self, name, color): if not self.has_curve(name): curve = Curve(self.db, name, color) self.curves.append(curve) + self.change_active_curve(curve) def clear_curves(self): self.curves.clear() + self.y_axis = Axis() + + def change_active_curve(self, curve): + self.activeCurve = curve + self.y_axis = self.activeCurve.axis def info(self): print(f"Chart with {len(self.curves)} series") diff --git a/python/lognplot/chart/curve.py b/python/lognplot/chart/curve.py index c127475..d65dfb9 100644 --- a/python/lognplot/chart/curve.py +++ b/python/lognplot/chart/curve.py @@ -1,5 +1,5 @@ from ..tsdb.aggregation import Aggregation - +from .axis import Axis class Curve: """ A curve is a view onto a signal in the database. @@ -14,6 +14,12 @@ def __init__(self, db, name, color): self._db = db self.name = name self.color = color + # Corresponding handle (polygon area) + self.handle = [] + # Corresponding legend segment (polygon area) + self.legend_segment = [] + # Each curve has its own vertical axis + self.axis = Axis() def __repr__(self): return "Database proxy-curve" diff --git a/python/lognplot/chart/legend.py b/python/lognplot/chart/legend.py new file mode 100644 index 0000000..52d42f9 --- /dev/null +++ b/python/lognplot/chart/legend.py @@ -0,0 +1,21 @@ +from enum import Enum + +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, + LegendMode.Y_AXIS_SCALE] + 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/base.py b/python/lognplot/qt/render/base.py index 0eb8e03..af1132a 100644 --- a/python/lognplot/qt/render/base.py +++ b/python/lognplot/qt/render/base.py @@ -35,7 +35,7 @@ def calc_y_ticks(self, axis): y_ticks = axis.get_ticks(amount_y_ticks) return y_ticks - def draw_grid(self, x_ticks, y_ticks): + def draw_grid(self, y_axis, x_ticks, y_ticks): """ Render a grid on the given x and y tick markers. """ pen = QtGui.QPen(Qt.gray) pen.setWidth(1) @@ -46,7 +46,7 @@ def draw_grid(self, x_ticks, y_ticks): self.painter.drawLine(x, self.layout.chart_top, x, self.layout.chart_bottom) for value, _ in y_ticks: - y = self.to_y_pixel(value) + y = self.to_y_pixel(y_axis, value) self.painter.drawLine(self.layout.chart_left, y, self.layout.chart_right, y) def draw_x_axis(self, x_ticks): @@ -69,7 +69,7 @@ def draw_x_axis(self, x_ticks): text_y = y + 10 - text_rect.y() self.painter.drawText(text_x, text_y, label) - def draw_y_axis(self, y_ticks): + def draw_y_axis(self, y_axis, y_ticks): """ Draw the Y-axis. """ pen = QtGui.QPen(Qt.black) pen.setWidth(2) @@ -77,7 +77,7 @@ def draw_y_axis(self, y_ticks): x = self.layout.chart_right + 5 self.painter.drawLine(x, self.layout.chart_top, x, self.layout.chart_bottom) for value, label in y_ticks: - y = self.to_y_pixel(value) + y = self.to_y_pixel(y_axis, value) # Tick handle: self.painter.drawLine(x, y, x + 5, y) diff --git a/python/lognplot/qt/render/chart.py b/python/lognplot/qt/render/chart.py index 5ec65b1..a91d911 100644 --- a/python/lognplot/qt/render/chart.py +++ b/python/lognplot/qt/render/chart.py @@ -1,5 +1,5 @@ from ..qtapi import QtGui, QtCore, Qt -from ...chart import Chart +from ...chart import Axis, Chart from ...utils import bench_it from ...tsdb import Aggregation from .layout import ChartLayout @@ -25,16 +25,19 @@ def render(self): y_ticks = self.calc_y_ticks(self.chart.y_axis) if self.options.show_grid: - self.draw_grid(x_ticks, y_ticks) + self.draw_grid(self.chart.y_axis, x_ticks, y_ticks) self.draw_bouding_rect() if self.options.show_axis: self.draw_x_axis(x_ticks) - self.draw_y_axis(y_ticks) + self.draw_y_axis(self.chart.y_axis, y_ticks) + + if self.options.show_handles: + self._draw_handles() self._draw_curves() - self._draw_legend() + self._draw_cursor() def shade_region(self, region): @@ -77,17 +80,17 @@ def _draw_curve(self, curve): if data: if isinstance(data[0], Aggregation): - self._draw_aggregations_as_shape(data, curve_color) + self._draw_aggregations_as_shape(curve.axis, data, curve_color) else: - self._draw_samples_as_lines(data, curve_color) + self._draw_samples_as_lines(curve.axis, data, curve_color) - def _draw_samples_as_lines(self, samples, curve_color: QtGui.QColor): + def _draw_samples_as_lines(self, y_axis: Axis, samples, curve_color: QtGui.QColor): """ Draw raw samples as lines! """ pen = QtGui.QPen(curve_color) pen.setWidth(2) self.painter.setPen(pen) points = [ - QtCore.QPoint(self.to_x_pixel(x), self.to_y_pixel(y)) for (x, y) in samples + QtCore.QPoint(self.to_x_pixel(x), self.to_y_pixel(y_axis, y)) for (x, y) in samples ] line = QtGui.QPolygon(points) self.painter.drawPolyline(line) @@ -98,7 +101,7 @@ def _draw_samples_as_lines(self, samples, curve_color: QtGui.QColor): self.painter.drawEllipse(rect) def _draw_aggregations_as_shape( - self, aggregations: Aggregation, curve_color: QtGui.QColor + self, y_axis: Axis, aggregations: Aggregation, curve_color: QtGui.QColor ): """ Draw aggregates as polygon shapes. @@ -119,12 +122,12 @@ def _draw_aggregations_as_shape( # x2 = self.to_x_pixel(metric.x2) # max line: - y_max = self.to_y_pixel(aggregation.metrics.maximum) + y_max = self.to_y_pixel(y_axis, aggregation.metrics.maximum) max_points.append(QtCore.QPoint(x1, y_max)) # max_points.append(QtCore.QPoint(x2, y_max)) # min line: - y_min = self.to_y_pixel(aggregation.metrics.minimum) + y_min = self.to_y_pixel(y_axis, aggregation.metrics.minimum) min_points.append(QtCore.QPoint(x1, y_min)) # min_points.append(QtCore.QPoint(x2, y_min)) @@ -132,17 +135,17 @@ def _draw_aggregations_as_shape( stddev = aggregation.metrics.stddev # Mean line: - y_mean = self.to_y_pixel(mean) + y_mean = self.to_y_pixel(y_axis, mean) mean_points.append(QtCore.QPoint(x1, y_mean)) # mean_points.append(QtCore.QPoint(x2, y_mean)) # stddev up line: - y_stddev_up = self.to_y_pixel(mean + stddev) + y_stddev_up = self.to_y_pixel(y_axis, mean + stddev) stddev_up_points.append(QtCore.QPoint(x1, y_stddev_up)) # stddev_up_points.append(QtCore.QPoint(x2, y_stddev_up)) # stddev down line: - y_stddev_down = self.to_y_pixel(mean - stddev) + y_stddev_down = self.to_y_pixel(y_axis, mean - stddev) stddev_down_points.append(QtCore.QPoint(x1, y_stddev_down)) # stddev_down_points.append(QtCore.QPoint(x2, y_stddev_down)) @@ -188,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: @@ -240,7 +218,7 @@ def _draw_cursor(self): pen.setWidth(2) self.painter.setPen(pen) marker_x = self.to_x_pixel(curve_point_timestamp) - marker_y = self.to_y_pixel(curve_point_value) + marker_y = self.to_y_pixel(curve.axis, curve_point_value) marker_size = 10 indicator_rect = QtCore.QRect( marker_x - marker_size // 2, @@ -251,27 +229,53 @@ 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() + + for _, curve in enumerate(self.chart.curves): + 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 + + curve.handle = [ + QtCore.QPointF(x, handle_y - y_half), + QtCore.QPointF(x, handle_y - y_half), + QtCore.QPointF(x + x_half, handle_y - y_half), + QtCore.QPointF(x + x_full, handle_y), + QtCore.QPointF(x + x_half, handle_y + y_half), + QtCore.QPointF(x, handle_y + y_half) + ] + + polygon = QtGui.QPainterPath(curve.handle[0]) + for p in curve.handle[1:]: + polygon.lineTo(p) + + color = QtGui.QColor(curve.color) + self.painter.fillPath(polygon, QtGui.QBrush(color)) def to_x_pixel(self, value): return transform.to_x_pixel(value, self.chart.x_axis, self.layout) - def to_y_pixel(self, value): - return transform.to_y_pixel(value, self.chart.y_axis, self.layout) + def to_y_pixel(self, y_axis, value): + return transform.to_y_pixel(value, y_axis, self.layout) def x_pixel_to_domain(self, pixel): axis = self.chart.x_axis diff --git a/python/lognplot/qt/render/layout.py b/python/lognplot/qt/render/layout.py index b9b5e20..3c1b21e 100644 --- a/python/lognplot/qt/render/layout.py +++ b/python/lognplot/qt/render/layout.py @@ -18,8 +18,6 @@ def __init__(self, rect: QtCore.QRect, options): 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_axis: axis_height = self.axis_height axis_width = self.axis_width @@ -27,6 +25,27 @@ def do_layout(self): axis_height = 0 axis_width = 0 + 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.legend.bottom() + 10 + 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/legend.py b/python/lognplot/qt/render/legend.py new file mode 100644 index 0000000..c6af6aa --- /dev/null +++ b/python/lognplot/qt/render/legend.py @@ -0,0 +1,99 @@ +from ..qtapi import QtGui, QtCore, Qt +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 + ): + 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 + + 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) + + 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) + 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() + 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) + + 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 + + text = format(curve_point_value, '.08g') + self._draw_text(x, curve, text) + + def _draw_signal_names(self, x, curve: Curve): + self._draw_text(x, curve, curve.name) + + 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)) diff --git a/python/lognplot/qt/render/options.py b/python/lognplot/qt/render/options.py index e355f84..82c5a72 100644 --- a/python/lognplot/qt/render/options.py +++ b/python/lognplot/qt/render/options.py @@ -2,4 +2,11 @@ class ChartOptions: def __init__(self): self.show_axis = True self.show_grid = True + self.show_legend = True + self.show_handles = True + self.autoscale_y_axis = False + self.show_cursor_legend = False self.padding = 10 + self.handle_width = 20 + self.handle_height = 15 + 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/basewidget.py b/python/lognplot/qt/widgets/basewidget.py index 71e01ef..9baca82 100644 --- a/python/lognplot/qt/widgets/basewidget.py +++ b/python/lognplot/qt/widgets/basewidget.py @@ -24,6 +24,7 @@ def mousePressEvent(self, event): super().mousePressEvent(event) self.disable_tailing() self._mouse_drag_source = event.x(), event.y() + self.mousePress(event.x(), event.y()) self.update() def mouseMoveEvent(self, event): @@ -36,6 +37,7 @@ def mouseReleaseEvent(self, event): super().mouseReleaseEvent(event) self._update_mouse_pan(event.x(), event.y()) self._mouse_drag_source = None + self.mouseRelease(event.x(), event.y()) def _update_mouse_pan(self, x, y): if self._mouse_drag_source: @@ -43,12 +45,18 @@ def _update_mouse_pan(self, x, y): if x != x0 or y != y0: dy = y - y0 dx = x - x0 + self.mouseDrag(x, y, dx, dy) self.pan(dx, dy) self._mouse_drag_source = (x, y) self.update() - def mouse_move(self, x, y): - """ Intended for override. """ + def mousePress(self, x, y): + pass + + def mouseRelease(self, x, y): + pass + + def mouseDrag(self, x, y, dx, dy): pass def pan(self, dx, dy): diff --git a/python/lognplot/qt/widgets/chartwidget.py b/python/lognplot/qt/widgets/chartwidget.py index 8c99a97..1d0b494 100644 --- a/python/lognplot/qt/widgets/chartwidget.py +++ b/python/lognplot/qt/widgets/chartwidget.py @@ -7,7 +7,7 @@ from ..qtapi import QtCore, QtWidgets, QtGui, Qt, pyqtSignal from ...utils import bench_it -from ...chart import Chart +from ...chart import Chart, Curve from ..render import render_chart_on_qpainter, ChartLayout, ChartOptions from ..render import transform from . import mime @@ -78,11 +78,51 @@ def mouse_move(self, x, y): self.chart.set_cursor(value) self.update() + def curveHandleAtPoint(self, x, y) -> Curve: + for curve in self.chart.curves: + topleft = curve.handle[0] + middleright = curve.handle[3] + bottomleft = curve.handle[-1] + if (x >= topleft.x() and + x <= middleright.x() and + y >= topleft.y() and + y <= bottomleft.y() + ): + return curve + return None + + def legendSegmentAtPoint(self, x, y) -> Curve: + for curve in self.chart.curves: + 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 + 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.legendSegmentAtPoint(x,y) + if curve == self.chart.activeCurve: + self.chart.legend.next_mode() + if curve is not None: + if curve != self.chart.activeCurve: + 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.chart.activeCurve.axis.pan_relative(dy / self.rect().height()) self.update() def add_curve(self, name, color=None): @@ -108,17 +148,22 @@ 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() def vertical_zoom(self, amount): self.chart.vertical_zoom(amount) + self.repaint() self.update() def horizontal_pan(self, amount): self.chart.horizontal_pan_relative(amount) # Autoscale Y for a nice effect? - self.chart.autoscale_y() + if self.chart_options.autoscale_y_axis: + self.chart.autoscale_y() + self.repaint() self.update() def vertical_pan(self, amount): @@ -128,12 +173,14 @@ def vertical_pan(self, amount): def zoom_fit(self): """ Autoscale all in fit! """ self.chart.zoom_fit() + self.repaint() self.update() def zoom_to_last(self, span): """ Zoom to fit the last x time in view. """ self.chart.zoom_to_last(span) + self.repaint() self.update() def enable_tailing(self, timespan):