feat: 流量波动图支持 1/5/30 分钟时间窗口切换

This commit is contained in:
2026-08-09 00:21:57 +08:00
parent 84e56c547c
commit 77039d807c
2 changed files with 99 additions and 22 deletions
+94 -20
View File
@@ -2,8 +2,8 @@
//
// SPDX-License-Identifier: LGPL-3.0-or-later
// 流量波动图窗口:屏幕居中的独立顶层窗口,展示当前活动接口最近 5 分钟的
// 网速趋势(下载/上传双折线图),随 C++ 后端 speedHistoryChanged 信号每秒动态刷新
// 流量波动图窗口:屏幕居中的独立顶层窗口,展示当前活动接口最近 1/5/30 分钟(可切换)
// 的网速趋势(下载/上传双折线图),随 C++ 后端 speedHistoryChanged 信号每秒动态刷新
// 设计要点:
// - 视觉风格复用 AboutWindow.qml(圆角卡片 12px、1px 边框、44px 标题栏、
// 28x28 圆形关闭按钮 hover 淡红底);深/浅主题由 isDarkMode 切换
@@ -67,6 +67,31 @@ Window {
// 格式:"14:23:05 ↓856.00 KB/s ↑120.00 KB/s"
property string hoverHint: ""
// 时间窗口选择(秒):60=1分钟, 300=5分钟, 1800=30分钟
// 纯 QML 展示层选择——后端已存够 30 分钟历史(MAX_HISTORY_SAMPLES=1800),
// 这里仅决定 X 轴绘制范围与标签,不改变后端采集行为
property int timeWindowSec: 300
// 时间窗口选项列表:由 (label, seconds) 键值对组成,供顶部状态条选择器遍历
// 及绘制逻辑按 timeWindowSec 反查标签/刻度使用
property var timeWindows: [
{ label: "1m", seconds: 60 },
{ label: "5m", seconds: 300 },
{ label: "30m", seconds: 1800 }
]
// 依据当前 timeWindowSec 反查 X 轴刻度标签数组:
// 设计原因:不同时间窗口下刻度粒度不同,1 分钟用 15 秒间隔、5 分钟用 1 分钟间隔、
// 30 分钟用 5 分钟间隔,保证标签数量适中(5~7 个)且刻度易读
readonly property var xAxisLabels: {
if (timeWindowSec <= 60) return ["-1m", "-45s", "-30s", "-15s", "now"]
if (timeWindowSec <= 300) return ["-5m", "-4m", "-3m", "-2m", "-1m", "now"]
return ["-30m", "-25m", "-20m", "-15m", "-10m", "-5m", "now"]
}
// 顶部状态条右侧的时间窗口标签文本(如 "· 5min"),随 timeWindowSec 动态显示
readonly property string timeWindowLabel: "· " + (timeWindowSec / 60) + "min"
// 窗口置顶状态:true 时 flags 附加 Qt.WindowStaysOnTopHint,窗口保持在
// 所有非置顶窗口之上(便于边下载大文件边观察曲线);由标题栏图钉按钮切换
property bool pinned: false
@@ -282,9 +307,55 @@ Window {
Item { Layout.fillWidth: true }
// 时间窗口选择器:三个小按钮 1m / 5m / 30m,当前选中项用 accentBlue 高亮
// 设计原因:时间窗口切换是图表最常用的操作,直接置于顶部状态条便于快速切换;
// 用比接口 chip 更小的尺寸(22x18)避免挤占状态条空间
Repeater {
model: root.timeWindows
Rectangle {
required property var modelData
property bool isSelected: root.timeWindowSec === modelData.seconds
width: 24
height: 18
radius: 4
// 选中态:蓝色填充+白字;未选中态:透明背景+浅边框,hover 边框变蓝
color: isSelected ? common.accentBlue : "transparent"
border.width: 1
border.color: isSelected
? common.accentBlue
: (mouse.containsMouse ? common.accentBlue : theme.lineColor)
Text {
anchors.centerIn: parent
text: modelData.label
font.pixelSize: 10
font.weight: isSelected ? Font.Bold : Font.Normal
color: isSelected ? "white" : (mouse.containsMouse ? common.accentBlue : theme.textSecondary)
}
MouseArea {
id: mouse
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onClicked: {
if (root.timeWindowSec !== modelData.seconds) {
root.timeWindowSec = modelData.seconds
// 时间窗口变化后:清空 Hover(坐标系已变),并重绘图表
root.hoverIndex = -1
root.hoverHint = ""
canvas.requestPaint()
}
}
}
}
}
Text {
text: (root.applet && root.applet.activeInterface
? root.applet.activeInterface : "—") + " · 5min"
? root.applet.activeInterface : "—") + " " + root.timeWindowLabel
font.pixelSize: 12
color: theme.textTertiary
}
@@ -322,11 +393,12 @@ Window {
var n = dl.length
// ---- Y 轴上限计算(动态适应最近活动)----
// 使用最近 60 秒的数据计算 Y 轴上限,而非全量 5 分钟缓冲。
// 使用最近 60 秒的数据计算 Y 轴上限,而非全量历史缓冲。
// 设计原因:若使用全量缓冲,当历史中存在大流量峰值(如 12MB/s 下载)时,
// 即使当前网速已降至 KB/s 级别,Y 轴仍保持高位,导致当前曲线被压到底部
// 无法观察波动。改用 60 秒窗口后,峰值滑出窗口时 Y 轴自动缩小,
// 始终为当前活动提供合适的显示比例。
// 始终为当前活动提供合适的显示比例。30 分钟视图下 60 秒窗口仍合理
//(关注近期活动),故不随 timeWindowSec 变化。
var vMax = 0
var i
var recentWindow = 60 // 秒,Y 轴基于最近 1 分钟的活动计算
@@ -360,10 +432,11 @@ Window {
ctx.fillText(root.formatAxisValue(yMax * g / 4, unit), pl - 8, gy)
}
// ---- X 轴刻度:每分钟一个标签,均布于绘图区 ----
// 5 分钟窗口用 -5m/-4m/-3m/-2m/-1m/now 共 6 个标签,比 4 个三分点
// 的 -3m20s/-1m40s 更易读;循环按数组长度参数化,两端左/右对齐
var xLabels = ["-5m", "-4m", "-3m", "-2m", "-1m", "now"]
// ---- X 轴刻度:按时间窗口参数化,均布于绘图区 ----
// 标签数组由 root.xAxisLabels 依据 timeWindowSec 动态生成
// (1 分钟用 15 秒间隔 5 个标签,5 分钟用 1 分钟间隔 6 个,
// 30 分钟用 5 分钟间隔 7 个);循环按数组长度参数化,两端左/右对齐
var xLabels = root.xAxisLabels
ctx.fillStyle = root.axisTextColor
ctx.textBaseline = "alphabetic"
for (i = 0; i < xLabels.length; i++) {
@@ -384,15 +457,16 @@ Window {
}
// ---- 折线绘制 ----
// X 轴固定为 5 分钟窗口 [tMax-300, tMax]:右缘始终对齐"now",
// 数据不足 5 分钟时曲线从右侧向左生长,左侧留空网格。
// X 轴为可配置窗口 [tMax-timeWindowSec, tMax]:右缘始终对齐"now",
// 数据不足时间窗口时曲线从右侧向左生长,左侧留空网格。
// 设计原因:若按实际数据范围 [dl[0].x, tMax] 映射,刚启动时仅有
// 几秒数据会被拉伸到整个绘图宽度,X 轴标签 -5m/-4m/... 与实际范围
// 严重不符,用户看到的是"几秒趋势图"而非"5 分钟趋势图"。
// 300 = 5 分钟 × 60 秒,与 C++ MAX_HISTORY_SAMPLES 一致
// 几秒数据会被拉伸到整个绘图宽度,X 轴标签与实际范围严重不符,
// 用户看到的是"几秒趋势图"而非"时间窗口趋势图"。
// tRange 取 timeWindowSec(60/300/1800),对应 1/5/30 分钟窗口,
// 与 C++ MAX_HISTORY_SAMPLES(1800)保证 30 分钟窗口下数据充足
var tMax = dl[n - 1].x
var tMin = tMax - 300
var tRange = 300
var tRange = root.timeWindowSec
var tMin = tMax - tRange
// 单点边界:只有 1 个采样点时无法连线,画一个圆点。
// 固定窗口下单点对齐 now(右缘),而非居中
@@ -487,7 +561,7 @@ Window {
// Hover 交互层:横向移动时按 mouseX 反查最近采样点
// 设计原因:采样点按时间均匀分布(1Hz),先由 X 坐标线性反推时间戳,
// 再线性扫描找最近点;300 点上限下扫描开销可忽略,无需二分
// 再线性扫描找最近点;1800 点上限下扫描开销仍可忽略,无需二分
MouseArea {
id: chartHover
anchors.fill: parent
@@ -501,11 +575,11 @@ Window {
var pl = root.chartMarginLeft
var pw = width - pl - root.chartMarginRight
if (pw <= 0) return
// X 轴用与绘制一致的固定 5 分钟窗口 [tMax-300, tMax] 反推时间戳,
// X 轴用与绘制一致的可配置窗口 [tMax-timeWindowSec, tMax] 反推时间戳,
// 保证 Hover 辅助线与折线上的采样点严格对齐
var tMax = dl[n - 1].x
var tMin = tMax - 300
var tRange = 300
var tRange = root.timeWindowSec
var tMin = tMax - tRange
// 将 mouseX 限制在绘图区内,再线性反推时间戳
var clampedX = Math.max(pl, Math.min(mouse.x, pl + pw))
var target = tMin + (clampedX - pl) / pw * tRange
+5 -2
View File
@@ -237,8 +237,11 @@ private:
// 速度历史环形缓冲:每个接口独立维护一份,key 为接口名
// 设计原因:接口切换时趋势图不出现跳变,切回时仍能看到该接口历史
QHash<QString, QVector<SpeedSample>> m_speedHistory;
// 每个接口最多保留的采样点数:5 分钟 × 60 秒 = 300
static constexpr int MAX_HISTORY_SAMPLES = 300;
// 每个接口最多保留的采样点数:30 分钟 × 60 秒 = 1800
// 设计原因:流量波动图支持 1/5/30 分钟时间窗口切换,需在展示层选择显示范围,
// 故后端需存够 30 分钟的历史数据;时间窗口选择是纯 QML 展示层行为,后端只需
// 提供足够长的历史缓冲,无需新增 Q_PROPERTY
static constexpr int MAX_HISTORY_SAMPLES = 1800;
// 历史 QVariantList 缓存:避免 QML 每次读取都重新构造 300 个 QPointF
// 设计原因:Canvas 每秒读取一次 + hover 时也读取,逐个 append 开销可观;