Files
2026-09-18 16:53:40 +08:00

501 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Render lightweight time-series metrics with Qt's painting primitives."""
from __future__ import annotations
from Furious.Frozenlib import APP
from Furious.Qt import AppStyleSheet
from Furious.Qt import gettext as _
from Furious.Service.MetricsHistory import MetricSeriesPoint
from PySide6 import QtCore, QtGui
from PySide6.QtWidgets import QSizePolicy, QToolTip, QWidget
from typing import Callable, Iterable
import bisect
import time
__all__ = ['MetricsGraphWidget']
class MetricsGraphWidget(QWidget):
"""Paint one prepared metric series without owning collection logic."""
Download = 'download'
Upload = 'upload'
Directions = (Download, Upload)
LeftMargin = 82
TopMargin = 16
RightMargin = 16
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."""
super().__init__(parent)
if direction not in self.Directions:
raise ValueError(f'unsupported metrics direction: {direction}')
if not callable(valueFormatter):
raise TypeError('valueFormatter must be callable')
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,
QSizePolicy.Policy.Expanding,
)
def setSeries(
self,
points: Iterable[MetricSeriesPoint],
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, MetricSeriesPoint)
)
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):
"""Return the current application palette for custom painting."""
return AppStyleSheet.paletteForTheme(APP().theme())
def _lineColor(self, palette):
"""Return the semantic line color for this traffic direction."""
return QtGui.QColor(palette[f'metrics_{self._direction}'])
def _fillColor(self, palette):
"""Return the semantic area-fill color for this traffic direction."""
return QtGui.QColor(palette[f'metrics_{self._direction}_fill'])
@staticmethod
def _relativeTimeLabel(seconds: float) -> str:
"""Return a compact negative offset label for the horizontal axis."""
seconds = max(float(seconds), 0.0)
if seconds >= 3600:
hours = seconds / 3600
value = f'{hours:.1f}'.rstrip('0').rstrip('.')
return f'-{value}h'
if seconds >= 60:
minutes = seconds / 60
value = f'{minutes:.1f}'.rstrip('0').rstrip('.')
return f'-{value}m'
return f'-{int(round(seconds))}s'
def _chartRect(self) -> QtCore.QRectF:
"""Return the drawable plot rectangle inside axis-label margins."""
return QtCore.QRectF(
self.LeftMargin,
self.TopMargin,
max(self.width() - self.LeftMargin - self.RightMargin, 1),
max(self.height() - self.TopMargin - self.BottomMargin, 1),
)
def _drawGrid(self, painter, chartRect, palette, maximumValue):
"""Draw subtle Fluent-style grid lines and compact axis labels."""
gridPen = QtGui.QPen(QtGui.QColor(palette['metrics_grid']))
gridPen.setWidthF(1.0)
painter.setPen(gridPen)
labelColor = QtGui.QColor(palette['metrics_axis'])
labelFont = QtGui.QFont(self.font())
labelFont.setPointSizeF(max(labelFont.pointSizeF() - 1, 7))
painter.setFont(labelFont)
for index in range(self.HorizontalGridLines + 1):
ratio = index / self.HorizontalGridLines
y = chartRect.bottom() - (ratio * chartRect.height())
painter.drawLine(
QtCore.QPointF(chartRect.left(), y),
QtCore.QPointF(chartRect.right(), y),
)
value = maximumValue * ratio
labelRect = QtCore.QRectF(
0,
y - 10,
self.LeftMargin - 8,
20,
)
painter.setPen(labelColor)
painter.drawText(
labelRect,
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignVCenter,
self._valueFormatter(value),
)
painter.setPen(gridPen)
for index in range(self.VerticalGridLines + 1):
ratio = index / self.VerticalGridLines
x = chartRect.left() + (ratio * chartRect.width())
painter.drawLine(
QtCore.QPointF(x, chartRect.top()),
QtCore.QPointF(x, chartRect.bottom()),
)
if index == self.VerticalGridLines:
label = _('Now')
else:
label = self._relativeTimeLabel(self._rangeSeconds * (1.0 - ratio))
labelRect = QtCore.QRectF(
x - 40,
chartRect.bottom() + 5,
80,
self.BottomMargin - 5,
)
painter.setPen(labelColor)
painter.drawText(
labelRect,
QtCore.Qt.AlignmentFlag.AlignHCenter | QtCore.Qt.AlignmentFlag.AlignTop,
label,
)
painter.setPen(gridPen)
def _drawEmptyState(self, painter, chartRect, palette):
"""Describe why a graph has no line without fabricating data."""
painter.setPen(QtGui.QColor(palette['metrics_axis']))
painter.drawText(
chartRect,
QtCore.Qt.AlignmentFlag.AlignCenter,
_('Waiting for network statistics'),
)
def _pointPosition(self, point, chartRect, maximumValue):
"""Map one metric point into plot coordinates."""
xRatio = self._normalizedTimePosition(
point.sampledAt,
self._rangeSeconds,
self._currentTime,
)
yRatio = min(max(point.value / maximumValue, 0.0), 1.0)
return QtCore.QPointF(
chartRect.left() + (xRatio * chartRect.width()),
chartRect.bottom() - (yRatio * chartRect.height()),
)
@staticmethod
def _normalizedTimePosition(sampledAt, rangeSeconds, currentTime) -> float:
"""Map an absolute timestamp into a rolling window's normalized x-axis."""
rangeSeconds = max(float(rangeSeconds), 1.0)
startTime = float(currentTime) - rangeSeconds
return min(
max((float(sampledAt) - startTime) / rangeSeconds, 0.0),
1.0,
)
def _graphPath(self, chartRect, maximumValue):
"""Convert prepared metric points into a painter path."""
path = QtGui.QPainterPath()
for index, point in enumerate(self._points):
pointPosition = self._pointPosition(point, chartRect, maximumValue)
if index == 0:
path.moveTo(pointPosition)
else:
path.lineTo(pointPosition)
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."""
def timestampFor(monotonicTime):
"""Translate one sample's monotonic time to local wall time."""
recordedAt = self._currentWallTime - (self._currentTime - monotonicTime)
return QtCore.QDateTime.fromMSecsSinceEpoch(
int(recordedAt * 1000)
).toString('yyyy-MM-dd HH:mm:ss')
timestamp = timestampFor(point.sampledAt)
if point.isAggregated:
firstSampledAt = (
point.sampledAt
if point.firstSampledAt is None
else point.firstSampledAt
)
lastSampledAt = (
point.sampledAt if point.lastSampledAt is None else point.lastSampledAt
)
timestamp = (
f'{timestampFor(firstSampledAt)} {timestampFor(lastSampledAt)}'
)
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)
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
palette = self._palette()
chartRect = self._chartRect()
maximumValue = self._maximumValue()
self._drawGrid(painter, chartRect, palette, maximumValue)
if not self._points:
self._drawEmptyState(painter, chartRect, palette)
painter.end()
return
path = self._graphPath(chartRect, maximumValue)
fillPath = QtGui.QPainterPath(path)
fillPath.lineTo(path.currentPosition().x(), chartRect.bottom())
fillPath.lineTo(path.elementAt(0).x, chartRect.bottom())
fillPath.closeSubpath()
fillColor = self._fillColor(palette)
transparentFill = QtGui.QColor(fillColor)
transparentFill.setAlpha(0)
gradient = QtGui.QLinearGradient(
chartRect.topLeft(),
chartRect.bottomLeft(),
)
gradient.setColorAt(0.0, fillColor)
gradient.setColorAt(1.0, transparentFill)
painter.fillPath(fillPath, gradient)
linePen = QtGui.QPen(self._lineColor(palette))
linePen.setWidthF(2.25)
linePen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
linePen.setJoinStyle(QtCore.Qt.PenJoinStyle.RoundJoin)
painter.setPen(linePen)
painter.drawPath(path)
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()