Add hover details to network metrics graphs

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:21:09 +08:00
parent 562e73a5a0
commit 06bfc7be7f
2 changed files with 197 additions and 16 deletions
+194 -16
View File
@@ -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()
+3
View File
@@ -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