mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
501 lines
16 KiB
Python
501 lines
16 KiB
Python
# Copyright (C) 2024–present 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()
|