mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-07 22:38:21 +03:00
Add hover details to network metrics graphs
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
+194
-16
@@ -25,10 +25,13 @@ from Furious.Qt import gettext as _
|
||||
from Furious.Service.MetricsDataManager import MetricPoint
|
||||
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtWidgets import QSizePolicy, QWidget
|
||||
from PySide6.QtWidgets import QSizePolicy, QToolTip, QWidget
|
||||
|
||||
from typing import Callable, Iterable
|
||||
|
||||
import bisect
|
||||
import time
|
||||
|
||||
__all__ = ['MetricsGraphWidget']
|
||||
|
||||
|
||||
@@ -45,6 +48,7 @@ class MetricsGraphWidget(QWidget):
|
||||
BottomMargin = 30
|
||||
HorizontalGridLines = 4
|
||||
VerticalGridLines = 4
|
||||
HoverDistance = 18
|
||||
|
||||
def __init__(self, direction: str, valueFormatter: Callable, parent=None):
|
||||
"""Initialize an empty graph for one traffic direction."""
|
||||
@@ -59,10 +63,15 @@ class MetricsGraphWidget(QWidget):
|
||||
self._direction = direction
|
||||
self._valueFormatter = valueFormatter
|
||||
self._points = tuple()
|
||||
self._pointTimes = tuple()
|
||||
self._rangeSeconds = 300.0
|
||||
self._currentTime = 0.0
|
||||
self._currentWallTime = 0.0
|
||||
self._metricLabel = ''
|
||||
self._hoveredPoint = None
|
||||
|
||||
self.setObjectName('MetricsGraphWidget')
|
||||
self.setMouseTracking(True)
|
||||
self.setMinimumSize(340, 220)
|
||||
self.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding,
|
||||
@@ -74,21 +83,38 @@ class MetricsGraphWidget(QWidget):
|
||||
points: Iterable[MetricPoint],
|
||||
rangeSeconds: float,
|
||||
currentTime: float,
|
||||
currentWallTime=None,
|
||||
):
|
||||
"""Replace the prepared series and schedule one repaint."""
|
||||
self._points = tuple(
|
||||
point for point in points if isinstance(point, MetricPoint)
|
||||
)
|
||||
self._pointTimes = tuple(point.sampledAt for point in self._points)
|
||||
self._rangeSeconds = max(float(rangeSeconds), 1.0)
|
||||
self._currentTime = float(currentTime)
|
||||
self._currentWallTime = (
|
||||
time.time() if currentWallTime is None else float(currentWallTime)
|
||||
)
|
||||
|
||||
if self.underMouse():
|
||||
self._updateHover(self.mapFromGlobal(QtGui.QCursor.pos()))
|
||||
else:
|
||||
self._clearHover()
|
||||
|
||||
self.update()
|
||||
|
||||
def setMetricLabel(self, label: str):
|
||||
"""Set the translated metric name displayed in hover details."""
|
||||
self._metricLabel = str(label)
|
||||
|
||||
def clear(self):
|
||||
"""Discard displayed points and schedule one repaint."""
|
||||
if not self._points:
|
||||
return
|
||||
|
||||
self._points = tuple()
|
||||
self._pointTimes = tuple()
|
||||
self._clearHover()
|
||||
self.update()
|
||||
|
||||
def _palette(self):
|
||||
@@ -209,21 +235,26 @@ class MetricsGraphWidget(QWidget):
|
||||
_('Waiting for network statistics'),
|
||||
)
|
||||
|
||||
def _pointPosition(self, point, chartRect, maximumValue):
|
||||
"""Map one metric point into plot coordinates."""
|
||||
startTime = self._currentTime - self._rangeSeconds
|
||||
xRatio = min(
|
||||
max((point.sampledAt - startTime) / self._rangeSeconds, 0.0),
|
||||
1.0,
|
||||
)
|
||||
yRatio = min(max(point.value / maximumValue, 0.0), 1.0)
|
||||
|
||||
return QtCore.QPointF(
|
||||
chartRect.left() + (xRatio * chartRect.width()),
|
||||
chartRect.bottom() - (yRatio * chartRect.height()),
|
||||
)
|
||||
|
||||
def _graphPath(self, chartRect, maximumValue):
|
||||
"""Convert prepared metric points into a painter path."""
|
||||
startTime = self._currentTime - self._rangeSeconds
|
||||
path = QtGui.QPainterPath()
|
||||
|
||||
for index, point in enumerate(self._points):
|
||||
xRatio = min(
|
||||
max((point.sampledAt - startTime) / self._rangeSeconds, 0.0),
|
||||
1.0,
|
||||
)
|
||||
yRatio = min(max(point.value / maximumValue, 0.0), 1.0)
|
||||
pointPosition = QtCore.QPointF(
|
||||
chartRect.left() + (xRatio * chartRect.width()),
|
||||
chartRect.bottom() - (yRatio * chartRect.height()),
|
||||
)
|
||||
pointPosition = self._pointPosition(point, chartRect, maximumValue)
|
||||
|
||||
if index == 0:
|
||||
path.moveTo(pointPosition)
|
||||
@@ -232,6 +263,154 @@ class MetricsGraphWidget(QWidget):
|
||||
|
||||
return path
|
||||
|
||||
def _maximumValue(self):
|
||||
"""Return the padded vertical-axis maximum for current points."""
|
||||
maximumValue = max(
|
||||
(point.value for point in self._points),
|
||||
default=0.0,
|
||||
)
|
||||
|
||||
return max(maximumValue * 1.1, 1.0)
|
||||
|
||||
def _nearestPoint(self, position):
|
||||
"""Return the point nearest the cursor's horizontal time position."""
|
||||
if not self._points:
|
||||
return None
|
||||
|
||||
chartRect = self._chartRect()
|
||||
|
||||
if not chartRect.contains(QtCore.QPointF(position)):
|
||||
return None
|
||||
|
||||
ratio = (position.x() - chartRect.left()) / chartRect.width()
|
||||
targetTime = (
|
||||
self._currentTime - self._rangeSeconds
|
||||
) + ratio * self._rangeSeconds
|
||||
insertionIndex = bisect.bisect_left(self._pointTimes, targetTime)
|
||||
candidates = []
|
||||
|
||||
if insertionIndex < len(self._points):
|
||||
candidates.append(self._points[insertionIndex])
|
||||
|
||||
if insertionIndex > 0:
|
||||
candidates.append(self._points[insertionIndex - 1])
|
||||
|
||||
nearest = min(
|
||||
candidates,
|
||||
key=lambda point: abs(point.sampledAt - targetTime),
|
||||
default=None,
|
||||
)
|
||||
|
||||
if nearest is None:
|
||||
return None
|
||||
|
||||
pointPosition = self._pointPosition(
|
||||
nearest,
|
||||
chartRect,
|
||||
self._maximumValue(),
|
||||
)
|
||||
|
||||
if abs(pointPosition.x() - position.x()) > self.HoverDistance:
|
||||
return None
|
||||
|
||||
return nearest
|
||||
|
||||
def _tooltipText(self, point) -> str:
|
||||
"""Return timestamp and formatted metric details for one point."""
|
||||
recordedAt = self._currentWallTime - (self._currentTime - point.sampledAt)
|
||||
timestamp = QtCore.QDateTime.fromMSecsSinceEpoch(
|
||||
int(recordedAt * 1000)
|
||||
).toString('yyyy-MM-dd HH:mm:ss')
|
||||
value = self._valueFormatter(point.value)
|
||||
|
||||
if self._metricLabel:
|
||||
value = f'{self._metricLabel}: {value}'
|
||||
|
||||
return f'{timestamp}\n{value}'
|
||||
|
||||
def _clearHover(self):
|
||||
"""Clear hover state and dismiss the native Qt tooltip."""
|
||||
changed = self._hoveredPoint is not None
|
||||
|
||||
self._hoveredPoint = None
|
||||
|
||||
if changed:
|
||||
QToolTip.hideText()
|
||||
|
||||
self.update()
|
||||
|
||||
def _updateHover(self, position):
|
||||
"""Select the nearest graph point and show its native tooltip."""
|
||||
point = self._nearestPoint(position)
|
||||
|
||||
if point is None:
|
||||
self._clearHover()
|
||||
|
||||
return
|
||||
|
||||
changed = point is not self._hoveredPoint
|
||||
|
||||
self._hoveredPoint = point
|
||||
|
||||
tooltipPosition = self.mapToGlobal(position) + QtCore.QPoint(14, 18)
|
||||
|
||||
QToolTip.showText(
|
||||
tooltipPosition,
|
||||
self._tooltipText(point),
|
||||
self,
|
||||
)
|
||||
|
||||
if changed:
|
||||
self.update()
|
||||
|
||||
def _drawHover(self, painter, chartRect, maximumValue, palette):
|
||||
"""Draw a crosshair and highlighted marker for the hovered point."""
|
||||
if self._hoveredPoint is None:
|
||||
return
|
||||
|
||||
pointPosition = self._pointPosition(
|
||||
self._hoveredPoint,
|
||||
chartRect,
|
||||
maximumValue,
|
||||
)
|
||||
|
||||
crosshairColor = QtGui.QColor(palette['metrics_axis'])
|
||||
crosshairColor.setAlpha(150)
|
||||
|
||||
crosshairPen = QtGui.QPen(crosshairColor)
|
||||
crosshairPen.setStyle(QtCore.Qt.PenStyle.DashLine)
|
||||
|
||||
painter.setPen(crosshairPen)
|
||||
painter.drawLine(
|
||||
QtCore.QPointF(pointPosition.x(), chartRect.top()),
|
||||
QtCore.QPointF(pointPosition.x(), chartRect.bottom()),
|
||||
)
|
||||
painter.setBrush(QtGui.QColor(palette['panel_alt']))
|
||||
|
||||
markerPen = QtGui.QPen(self._lineColor(palette))
|
||||
markerPen.setWidthF(2.5)
|
||||
|
||||
painter.setPen(markerPen)
|
||||
painter.drawEllipse(pointPosition, 5.0, 5.0)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
"""Inspect the graph point nearest the moving cursor."""
|
||||
self._updateHover(event.position().toPoint())
|
||||
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
"""Dismiss hover details when the cursor leaves the graph."""
|
||||
self._clearHover()
|
||||
|
||||
super().leaveEvent(event)
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Dismiss hover details when lazy page rendering becomes hidden."""
|
||||
self._clearHover()
|
||||
|
||||
super().hideEvent(event)
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Paint axes, an optional area fill, and the current metric line."""
|
||||
super().paintEvent(event)
|
||||
@@ -241,11 +420,7 @@ class MetricsGraphWidget(QWidget):
|
||||
|
||||
palette = self._palette()
|
||||
chartRect = self._chartRect()
|
||||
maximumValue = max(
|
||||
(point.value for point in self._points),
|
||||
default=0.0,
|
||||
)
|
||||
maximumValue = max(maximumValue * 1.1, 1.0)
|
||||
maximumValue = self._maximumValue()
|
||||
|
||||
self._drawGrid(painter, chartRect, palette, maximumValue)
|
||||
|
||||
@@ -287,4 +462,7 @@ class MetricsGraphWidget(QWidget):
|
||||
painter.setBrush(self._lineColor(palette))
|
||||
painter.setPen(QtCore.Qt.PenStyle.NoPen)
|
||||
painter.drawEllipse(path.currentPosition(), 3.5, 3.5)
|
||||
|
||||
self._drawHover(painter, chartRect, maximumValue, palette)
|
||||
|
||||
painter.end()
|
||||
|
||||
@@ -63,6 +63,7 @@ class _MetricCard(QFrame):
|
||||
def setTitle(self, title: str):
|
||||
"""Set the already translated metric title."""
|
||||
self.titleLabel.setText(title)
|
||||
self.graph.setMetricLabel(title)
|
||||
|
||||
|
||||
class _MetricsSection(QFrame):
|
||||
@@ -265,6 +266,7 @@ class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
|
||||
self.granularityComboBox.currentData() or self.DefaultGranularity
|
||||
)
|
||||
currentTime = time.monotonic()
|
||||
currentWallTime = time.time()
|
||||
graphMetrics = (
|
||||
(self.downloadSpeedGraph, DOWNLOAD_SPEED_METRIC),
|
||||
(self.downloadUsageGraph, DOWNLOAD_USAGE_METRIC),
|
||||
@@ -282,6 +284,7 @@ class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
|
||||
),
|
||||
rangeSeconds,
|
||||
currentTime,
|
||||
currentWallTime,
|
||||
)
|
||||
|
||||
self._dirty = False
|
||||
|
||||
Reference in New Issue
Block a user