You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1226 lines
48 KiB
Python

7 months ago
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QSize, Qt, QTimer, QEvent
7 months ago
from PyQt5.QtGui import QPixmap, QIcon
from PyQt5.QtWidgets import (QApplication, QGridLayout, QListWidget, QListWidgetItem,
QToolBar, QAction, QLabel, QWidget, QVBoxLayout, QHBoxLayout,
QSplitter, QGroupBox, QLineEdit, QComboBox, QTextEdit,
QCheckBox, QFrame, QSpacerItem, QSizePolicy, QHeaderView, QToolButton)
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage
7 months ago
from utils import Globals
import qtawesome
7 months ago
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt import NavigationToolbar2QT as NavigationToolbar
from matplotlib.figure import Figure
7 months ago
import numpy as np
import datetime
import sys
import pandas as pd
import bisect
from matplotlib.widgets import RectangleSelector
import matplotlib.patches as patches
from matplotlib.widgets import SpanSelector
from matplotlib.gridspec import GridSpec
# 配置中文字体支持
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
7 months ago
class TrendWidgets(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("历史趋势浏览器")
self.setMinimumSize(120, 120)
self.setObjectName("trendMainWidget")
# 初始化数据
self.historyDB = None
self.currentVarName = None
self.xData = []
self.yData = []
# 多变量支持
self.multiVarData = {} # 存储多个变量的数据 {varName: {'x': [], 'y': []}}
self.selectedVars = [] # 当前选中的变量列表
self.varColors = {} # 存储变量颜色 {varName: color}
# 初始化历史缩放记录
self.history = []
self.historyIndex = 0
self.initializeUI()
self.connectSignals()
Globals.setValue('HistoryWidget', self)
def initializeUI(self):
"""初始化用户界面"""
mainLayout = QHBoxLayout(self)
mainLayout.setSpacing(8)
mainLayout.setContentsMargins(8, 8, 8, 8)
# 左侧变量列表区域 (占25%宽度)
self.createVariableListPanel()
mainLayout.addWidget(self.variableListGroup, 1)
# 右侧趋势图区域 (占75%宽度)
self.trendViewerGroup = QWidget()
self.trendViewerGroup.setObjectName("trendViewerGroup")
self.createTrendViewerPanel()
mainLayout.addWidget(self.trendViewerGroup, 10)
def createVariableListPanel(self):
"""创建变量列表面板"""
self.variableListGroup = QGroupBox("变量列表")
self.variableListGroup.setObjectName("trendVariableListGroup")
layout = QVBoxLayout(self.variableListGroup)
layout.setSpacing(5)
layout.setContentsMargins(8, 8, 8, 8)
# 搜索框
self.searchInput = QLineEdit()
self.searchInput.setObjectName("trendSearchInput")
self.searchInput.setPlaceholderText("搜索变量...")
self.searchInput.textChanged.connect(self.filterVarList)
layout.addWidget(self.searchInput, 1)
3 months ago
# 变量列表
self.varListWidget = QListWidget()
self.varListWidget.setObjectName("trendVarListWidget")
3 months ago
self.varListWidget.itemDoubleClicked.connect(self.showVarTrend)
self.varListWidget.itemSelectionChanged.connect(self.onVarSelected)
layout.addWidget(self.varListWidget, 10)
# 时间选择区域
timeGroupBox = QGroupBox("时间范围")
timeGroupBox.setObjectName("trendTimeGroupBox")
timeLayout = QVBoxLayout(timeGroupBox)
timeLayout.setSpacing(6)
# 快速选择
self.quickRangeCombo = QtWidgets.QComboBox()
self.quickRangeCombo.setObjectName("trendQuickRangeCombo")
self.quickRangeCombo.addItems(["最近一天", "最近6小时", "最近1小时", "最近30分钟", "自定义"])
self.quickRangeCombo.currentIndexChanged.connect(self.onQuickRangeChanged)
timeLayout.addWidget(self.quickRangeCombo)
# 时间选择器
from PyQt5.QtCore import QDateTime
timeEditLayout = QVBoxLayout()
self.startTimeEdit = QtWidgets.QDateTimeEdit()
self.startTimeEdit.setObjectName("trendStartTimeEdit")
self.startTimeEdit.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
self.startTimeEdit.setCalendarPopup(True)
self.endTimeEdit = QtWidgets.QDateTimeEdit()
self.endTimeEdit.setObjectName("trendEndTimeEdit")
self.endTimeEdit.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
self.endTimeEdit.setCalendarPopup(True)
now = QDateTime.currentDateTime()
self.startTimeEdit.setDateTime(now.addDays(-1))
self.endTimeEdit.setDateTime(now)
timeEditLayout.addWidget(QLabel("开始时间:"))
timeEditLayout.addWidget(self.startTimeEdit)
timeEditLayout.addWidget(QLabel("结束时间:"))
timeEditLayout.addWidget(self.endTimeEdit)
timeLayout.addLayout(timeEditLayout)
layout.addWidget(timeGroupBox, 3)
# 按钮区域
buttonGroupBox = QGroupBox("操作")
buttonGroupBox.setObjectName("trendButtonGroupBox")
buttonLayout = QVBoxLayout(buttonGroupBox)
buttonLayout.setSpacing(6)
# 第一行按钮
firstRowLayout = QHBoxLayout()
firstRowLayout.setSpacing(6)
self.queryBtn = QToolButton()
self.queryBtn.setObjectName("trendQueryBtn")
self.queryBtn.setText("查询数据")
self.queryBtn.setIcon(qtawesome.icon('fa.search', color='#2277EF'))
self.queryBtn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.queryBtn.setIconSize(QSize(16, 16))
self.queryBtn.setToolTip("按时间范围查询变量数据")
self.queryBtn.clicked.connect(self.onTimeRangeQuery)
firstRowLayout.addWidget(self.queryBtn)
self.refreshBtn = QToolButton()
self.refreshBtn.setObjectName("trendRefreshBtn")
self.refreshBtn.setText("刷新列表")
self.refreshBtn.setIcon(qtawesome.icon('fa.refresh', color='#059669'))
self.refreshBtn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.refreshBtn.setIconSize(QSize(16, 16))
self.refreshBtn.setToolTip("刷新变量列表")
self.refreshBtn.clicked.connect(self.refreshVarList)
firstRowLayout.addWidget(self.refreshBtn, 2)
buttonLayout.addLayout(firstRowLayout)
# 第二行按钮
secondRowLayout = QHBoxLayout()
secondRowLayout.setSpacing(6)
self.addToTrendBtn = QToolButton()
self.addToTrendBtn.setObjectName("trendAddBtn")
self.addToTrendBtn.setText("添加变量")
self.addToTrendBtn.setIcon(qtawesome.icon('fa.plus', color='#7C3AED'))
self.addToTrendBtn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.addToTrendBtn.setIconSize(QSize(16, 16))
self.addToTrendBtn.setToolTip("将选中的变量添加到趋势图")
self.addToTrendBtn.clicked.connect(self.addSelectedVarsToTrend)
secondRowLayout.addWidget(self.addToTrendBtn)
self.clearAllBtn = QToolButton()
self.clearAllBtn.setObjectName("trendClearBtn")
self.clearAllBtn.setText("清除所有")
self.clearAllBtn.setIcon(qtawesome.icon('fa.trash', color='#DC2626'))
self.clearAllBtn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.clearAllBtn.setIconSize(QSize(16, 16))
self.clearAllBtn.setToolTip("清除趋势图中的所有变量")
self.clearAllBtn.clicked.connect(self.clearAllVars)
secondRowLayout.addWidget(self.clearAllBtn)
buttonLayout.addLayout(secondRowLayout)
layout.addWidget(buttonGroupBox)
# 记录当前时间范围
self._queryTimeRange = (self.startTimeEdit.dateTime().toPyDateTime(), self.endTimeEdit.dateTime().toPyDateTime())
def createTrendViewerPanel(self):
"""创建趋势图查看器面板"""
layout = QVBoxLayout(self.trendViewerGroup)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(8)
# 图表区域
chartGroup = QGroupBox("趋势图表")
chartGroup.setObjectName("trendChartGroup")
chartLayout = QVBoxLayout(chartGroup)
chartLayout.setContentsMargins(8, 8, 8, 8)
# 创建matplotlib图表
self.figure = Figure(figsize=(12, 8), dpi=100)
gs = GridSpec(7, 1, figure=self.figure)
self.axMain = self.figure.add_subplot(gs[:6, 0]) # 主图占6份
self.axOverview = self.figure.add_subplot(gs[6, 0]) # 缩略图占1份
self.canvas = FigureCanvas(self.figure)
chartLayout.addWidget(self.canvas)
layout.addWidget(chartGroup, 20)
# 信息状态栏
infoGroup = QGroupBox("状态信息")
infoGroup.setObjectName("trendInfoGroup")
infoLayout = QHBoxLayout(infoGroup)
infoLayout.setContentsMargins(8, 8, 8, 8)
self.varNameLabel = QLabel("当前变量: 无")
self.varNameLabel.setObjectName("trendVarNameLabel")
self.dataCountLabel = QLabel("数据点: 0")
self.dataCountLabel.setObjectName("trendDataCountLabel")
self.timeRangeLabel = QLabel("时间范围: 无数据")
self.timeRangeLabel.setObjectName("trendTimeRangeLabel")
self.statusLabel = QLabel("就绪")
self.statusLabel.setObjectName("trendStatusLabel")
infoLayout.addWidget(self.varNameLabel)
infoLayout.addStretch()
infoLayout.addWidget(self.dataCountLabel)
infoLayout.addStretch()
infoLayout.addWidget(self.timeRangeLabel)
infoLayout.addStretch()
infoLayout.addWidget(self.statusLabel)
layout.addWidget(infoGroup, 1)
# 初始化交互功能
self.initializeChartInteraction()
def onSelect(self, xmin, xmax):
"""SpanSelector回调记录选中区间并更新主趋势图"""
self._selectedRange = (xmin, xmax)
self.updateMultiVarChart()
def initializeChartInteraction(self):
"""初始化图表交互功能"""
# 悬浮信息气泡
self.infoBubble = QLabel(self.trendViewerGroup)
self.infoBubble.setObjectName("trendInfoBubble")
self.infoBubble.setVisible(False)
# 初始化十字标线
self.crosshair_v = None # 垂直线
self.crosshair_h = None # 水平线
self.crosshair_visible = False
self.crosshair_point = None # 交叉点圆点
self.crosshair_text = None # 坐标文本标签
self._snapped_var = None # 当前吸附的变量名
self.snap_enabled = True # 是否启用智能吸附功能
# 连接鼠标事件
self.canvas.mpl_connect('motion_notify_event', self.onMouseMove)
self.canvas.mpl_connect('axes_leave_event', self.onMouseLeave)
self.canvas.mpl_connect('axes_enter_event', self.onMouseEnter)
self.canvas.mpl_connect('scroll_event', self.onMouseWheel)
self.canvas.mpl_connect('button_press_event', self.onMousePress)
self.canvas.mpl_connect('button_release_event', self.onMouseRelease)
# 拖拽状态
self._isPanning = False
self._panStartX = None
self._panStartXlim = None
# 添加SpanSelector用于区间选择
self.span = SpanSelector(
self.axOverview, self.onSelect, 'horizontal',
useblit=True, interactive=True, props=dict(alpha=0.3, facecolor='orange')
)
# 绑定双击事件恢复全局视图
self.canvas.mpl_connect('button_press_event', self.onOverviewDoubleClick)
self._selectedRange = None # 记录选中区间
# 设置缩略图x轴显示格式
import matplotlib.dates as mdates
self.axOverview.xaxis.set_major_locator(mdates.AutoDateLocator())
self.axOverview.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
def connectSignals(self):
"""连接所有信号"""
self.axOverview.callbacks.connect('xlim_changed', self.onOverviewXlimChanged)
3 months ago
def exchangeProject(self):
# 项目切换时调用
# self.historyDB = None
3 months ago
self.historyDB = Globals.getValue('historyDBManage')
self.refreshVarList()
3 months ago
def refreshVarList(self):
# 刷新变量列表
3 months ago
self.varListWidget.clear()
self.statusLabel.setText("正在加载变量列表...")
if not self.historyDB:
self.statusLabel.setText("未连接历史数据库,无法获取变量列表")
self.historyDB = Globals.getValue('historyDBManage')
varNames = self.historyDB.getAllVarNames()
# print(varNames)
# 对变量名称进行排序
varNames.sort(key=lambda x: x.lower())
# 添加排序后的变量到列表
3 months ago
for name in varNames:
item = QListWidgetItem(name)
3 months ago
self.varListWidget.addItem(item)
self.statusLabel.setText(f"已加载 {len(varNames)} 个变量(已排序)")
def sortVarList(self):
# 手动排序变量列表
try:
varNames = []
for i in range(self.varListWidget.count()):
item = self.varListWidget.item(i)
if item and not item.isHidden():
varNames.append(item.text())
varNames.sort(key=lambda x: x.lower())
self.varListWidget.clear()
for name in varNames:
item = QListWidgetItem(name)
self.varListWidget.addItem(item)
self.statusLabel.setText(f"已排序 {len(varNames)} 个变量")
except Exception as e:
self.statusLabel.setText(f"排序失败: {str(e)}")
def addSelectedVarsToTrend(self):
# 将选中的变量添加到趋势图
selectedItems = self.varListWidget.selectedItems()
if not selectedItems:
self.statusLabel.setText("请先选择要添加的变量")
3 months ago
return
addedCount = 0
for item in selectedItems:
varName = item.text()
if varName not in self.selectedVars:
self.selectedVars.append(varName)
self.loadVarData(varName)
addedCount += 1
if addedCount > 0:
self.updateMultiVarChart()
self.statusLabel.setText(f"已添加 {addedCount} 个变量到趋势图")
else:
self.statusLabel.setText("选中的变量已在趋势图中")
def onTimeRangeQuery(self):
"""查询按钮点击,记录时间范围"""
self._queryTimeRange = (
self.startTimeEdit.dateTime().toPyDateTime(),
self.endTimeEdit.dateTime().toPyDateTime()
3 months ago
)
# 重新加载所有已选变量的数据
for varName in self.selectedVars:
self.loadVarData(varName)
self.updateMultiVarChart()
startTime = self._queryTimeRange[0].strftime('%Y-%m-%d %H:%M:%S')
endTime = self._queryTimeRange[1].strftime('%Y-%m-%d %H:%M:%S')
self.statusLabel.setText(f"已设置时间范围: {startTime} ~ {endTime}")
def loadVarData(self, varName):
"""加载单个变量的数据"""
try:
self.statusLabel.setText(f"加载 {varName} 数据...")
# 获取时间范围
if hasattr(self, '_queryTimeRange') and self._queryTimeRange:
startTime, endTime = self._queryTimeRange
else:
startTime = None
endTime = None
# 从数据库获取数据
if self.historyDB and hasattr(self.historyDB, 'queryVarHistory'):
data, timeList = self.historyDB.queryVarHistory(varName, startTime, endTime)
xData = self.processTimeData(timeList)
# 存储数据
self.multiVarData[varName] = {'x': xData, 'y': data}
# 分配颜色
if varName not in self.varColors:
self.assignVarColor(varName)
except Exception as e:
self.statusLabel.setText(f"加载 {varName} 数据失败: {str(e)}")
def processTimeData(self, timeList):
"""处理时间数据"""
import datetime
xData = []
for t in timeList:
if isinstance(t, datetime.datetime):
xData.append(t)
elif isinstance(t, pd.Timestamp):
xData.append(t.to_pydatetime())
elif isinstance(t, str):
try:
xData.append(datetime.datetime.fromisoformat(t))
except:
xData.append(datetime.datetime.now())
else:
xData.append(datetime.datetime.now())
return xData
def assignVarColor(self, varName):
"""为变量分配颜色"""
colors = ['#2277EF', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD']
colorIndex = len(self.varColors) % len(colors)
self.varColors[varName] = colors[colorIndex]
def updateMultiVarChart(self):
"""更新多变量图表"""
import matplotlib.dates as mdates
from matplotlib.ticker import MaxNLocator
# 清空图表
self.axMain.clear()
self.axOverview.clear()
# 重置十字标线引用因为clear()会删除所有线条)
self._cleanupCrosshair()
# 设置网格
self.axMain.grid(True, alpha=0.3)
self.axOverview.grid(True, alpha=0.3)
# 绘制所有选中的变量
for varName in self.selectedVars:
if varName in self.multiVarData:
data = self.multiVarData[varName]
if data['x'] and data['y']:
# 处理断线数据
xProcessed, yProcessed = self.processBreakLineData(data['x'], data['y'])
# 缩略图:显示全量数据
self.axOverview.plot(xProcessed, yProcessed,
color=self.varColors[varName],
linewidth=1, alpha=0.7)
# 主图:根据选中区间显示数据
xMain, yMain = self.filterDataByRange(xProcessed, yProcessed)
self.axMain.plot(xMain, yMain,
color=self.varColors[varName],
linewidth=2, marker='o', markersize=3,
label=varName)
# 设置格式和标签
self.setupChartFormat()
# 刷新画布
self.canvas.draw()
def processBreakLineData(self, xRaw, yRaw):
"""处理断线数据间隔大于2分钟的点不连线"""
x, y = [], []
prevTime = None
for xi, yi in zip(xRaw, yRaw):
if prevTime is not None and (xi - prevTime).total_seconds() > 120:
# 断线时插入None
x.append(xi)
y.append(None)
x.append(xi)
y.append(yi)
prevTime = xi
return x, y
def filterDataByRange(self, xProcessed, yProcessed):
"""根据选中区间过滤数据"""
if self._selectedRange is not None:
import matplotlib.dates as mdates
xlim = self._selectedRange
xFiltered, yFiltered = [], []
for xx, yy in zip(xProcessed, yProcessed):
if xx is not None:
xn = mdates.date2num(xx)
if xlim[0] <= xn <= xlim[1]:
xFiltered.append(xx)
yFiltered.append(yy)
return xFiltered, yFiltered
else:
return xProcessed, yProcessed
def setupChartFormat(self):
"""设置图表格式"""
import matplotlib.dates as mdates
from matplotlib.ticker import MaxNLocator
# 设置时间格式
self.axMain.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S'))
self.axMain.xaxis.set_major_locator(MaxNLocator(nbins=8))
self.axMain.yaxis.set_major_locator(MaxNLocator(nbins=8))
self.axOverview.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
self.axOverview.xaxis.set_major_locator(MaxNLocator(nbins=6))
self.axOverview.yaxis.set_major_locator(MaxNLocator(nbins=4))
# 设置标签
self.axMain.set_title('历史趋势图', fontsize=14, fontweight='bold', color='#2277EF')
self.axMain.set_ylabel('数值', fontsize=12)
self.axOverview.set_xlabel('时间', fontsize=10)
self.axOverview.set_ylabel('数值', fontsize=10)
# 添加图例
if self.selectedVars:
self.axMain.legend(loc='upper right', fontsize=10)
# 自动调整布局
self.figure.tight_layout()
def processBreakLineDataOld(self, xRaw, yRaw):
"""处理断线数据间隔大于2分钟的点不连线旧版本兼容"""
x, y = [], []
prevTime = None
for xi, yi in zip(xRaw, yRaw):
if prevTime is not None and (xi - prevTime).total_seconds() > 60:
# 断线时只在y中插入Nonex继续插入当前时间戳
x.append(xi)
y.append(None)
x.append(xi)
y.append(yi)
prevTime = xi
return x, y
def filterVarList(self, text):
# 本地过滤变量列表
for i in range(self.varListWidget.count()):
item = self.varListWidget.item(i)
if item is not None:
item.setHidden(text.lower() not in item.text().lower())
def onVarSelected(self):
# 当选中一个变量时(非双击)
selectedItems = self.varListWidget.selectedItems()
if selectedItems:
self.statusLabel.setText(f"已选择: {selectedItems[0].text()}")
def showVarTrend(self, item):
# 显示选中变量的趋势图(与多变量流程一致)
varName = item.text()
self.currentVarName = varName
self.statusLabel.setText(f"加载 {varName} 历史趋势...")
# 清空多变量数据,只保留当前变量
self.selectedVars = [varName]
self.multiVarData = {}
self.varColors = {}
self.loadVarData(varName)
# 更新UI信息
if varName in self.multiVarData:
data = self.multiVarData[varName]['y']
xdata = self.multiVarData[varName]['x']
else:
data = []
xdata = []
self.varNameLabel.setText(f"当前变量: {varName}")
self.dataCountLabel.setText(f"数据点: {len(data)}")
if xdata:
import datetime
import pandas as pd
def to_dt(t):
if isinstance(t, datetime.datetime):
return t
elif isinstance(t, pd.Timestamp):
return t.to_pydatetime()
elif isinstance(t, (int, float)):
return datetime.datetime.fromtimestamp(t)
else:
return datetime.datetime.now()
start_time = to_dt(xdata[0]).strftime('%Y-%m-%d %H:%M')
end_time = to_dt(xdata[-1]).strftime('%Y-%m-%d %H:%M')
self.timeRangeLabel.setText(f"时间范围: {start_time} - {end_time}")
else:
self.timeRangeLabel.setText("时间范围: 无数据")
# 刷新多变量趋势图
self.updateMultiVarChart()
self.resetZoom()
self.statusLabel.setText(f"{varName} 趋势已显示")
def updateChart(self):
"""用当前数据更新图表"""
self.axMain.clear()
self.axOverview.clear()
# 设置网格
self.axMain.grid(True, alpha=0.3)
self.axOverview.grid(True, alpha=0.3)
# 绘制数据
if self.xData and self.yData:
self.axMain.plot(self.xData, self.yData,
color='blue',
linewidth=2,
marker='o',
markersize=4,
label=self.currentVarName)
# 概览图(降采样)
step = max(1, len(self.xData) // 10)
xOverview = self.xData[::step]
yOverview = self.yData[::step]
self.axOverview.plot(xOverview, yOverview,
color='blue',
linewidth=1,
alpha=0.7)
# 设置时间格式
self.axMain.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S'))
self.axOverview.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S'))
# 设置标签
self.axMain.set_title('历史趋势图', fontsize=12, fontweight='bold')
self.axMain.set_ylabel('数值')
self.axOverview.set_xlabel('时间')
self.axOverview.set_ylabel('数值')
# 添加图例
if self.currentVarName:
self.axMain.legend(loc='upper right')
# 自动调整布局
self.figure.tight_layout()
# 刷新画布
self.canvas.draw()
def onMousePress(self, event):
"""鼠标按下事件"""
if event.inaxes == self.axMain and event.button == 1 and event.xdata is not None:
self._isPanning = True
self._panStartX = event.xdata
self._panStartXlim = self.axMain.get_xlim()
def onMouseRelease(self, event):
"""鼠标释放事件"""
if self._isPanning:
self._isPanning = False
self._panStartX = None
self._panStartXlim = None
self.canvas.draw_idle()
def onMouseMove(self, event):
"""鼠标移动事件"""
# 处理拖拽平移
if self._isPanning and event.inaxes == self.axMain and event.xdata is not None and self._panStartX is not None:
dx = event.xdata - self._panStartX
xlim0, xlim1 = self._panStartXlim
self.axMain.set_xlim(xlim0 - dx, xlim1 - dx)
# 更新十字标线(仅在主趋势图中)
if event.inaxes == self.axMain and event.xdata is not None and event.ydata is not None:
self.updateCrosshair(event.xdata, event.ydata)
self.showInfoBubble(event)
else:
self.hideCrosshair()
self.infoBubble.setVisible(False)
def onMouseEnter(self, event):
"""鼠标进入事件"""
if event.inaxes == self.axMain:
self.showCrosshair()
def onMouseLeave(self, event):
"""鼠标离开事件"""
if event.inaxes == self.axMain:
self.hideCrosshair()
self.infoBubble.setVisible(False)
def showInfoBubble(self, event):
"""显示信息气泡"""
infoText = f"时间: {mdates.num2date(event.xdata).strftime('%Y-%m-%d %H:%M:%S')}<br>"
if self.selectedVars:
for varName in self.selectedVars:
if varName in self.multiVarData:
data = self.multiVarData[varName]
if data['x'] and data['y']:
closestIdx = self.findClosestPoint(data['x'], event.xdata)
if closestIdx is not None and closestIdx < len(data['y']):
value = data['y'][closestIdx]
infoText += f"{varName}: {value:.3f}<br>"
self.infoBubble.setText(f"<span style='white-space:pre'>{infoText}</span>")
x = int(event.guiEvent.x())
y = int(event.guiEvent.y())
self.infoBubble.move(x + 16, y + 8)
self.infoBubble.adjustSize()
self.infoBubble.setVisible(True)
def findClosestPoint(self, xData, targetX):
"""找到最接近目标X值的数据点索引"""
if not xData:
return None
# 将datetime转换为matplotlib日期
if isinstance(xData[0], datetime.datetime):
xNums = mdates.date2num(xData)
else:
xNums = xData
# 使用二分查找
left = 0
right = len(xNums) - 1
while left <= right:
mid = (left + right) // 2
if xNums[mid] == targetX:
return mid
elif xNums[mid] < targetX:
left = mid + 1
else:
right = mid - 1
# 找到最接近的点
if left >= len(xNums):
return len(xNums) - 1
elif right < 0:
return 0
else:
if abs(xNums[left] - targetX) < abs(xNums[right] - targetX):
return left
else:
return right
def createCrosshair(self):
"""创建十字标线 - 使用安全的方式,不影响已有数据线"""
# 只有在所有组件都不存在时才创建
if (self.crosshair_v is None or self.crosshair_h is None or
self.crosshair_point is None or self.crosshair_text is None):
# 先检查是否有数据线存在,如果没有则不创建十字标线
existing_lines = self.axMain.get_lines()
data_lines = []
for line in existing_lines:
# 检查是否是十字标线(通过颜色判断)
try:
color = line.get_color()
if color != '#FF4444' and color != '#ff4444':
data_lines.append(line)
except:
# 如果获取颜色失败,假设是数据线
data_lines.append(line)
if len(data_lines) == 0:
# 没有数据线时不创建十字标线
# print("没有数据线,跳过十字标线创建")
return
try:
print(f"开始创建十字标线,发现{len(data_lines)}条数据线")
# 获取当前坐标轴范围,确保在有效范围内创建
xlim = self.axMain.get_xlim()
ylim = self.axMain.get_ylim()
# 使用Line2D对象而不是axvline/axhline更安全
from matplotlib.lines import Line2D
# 创建垂直线
self.crosshair_v = Line2D(
[xlim[0], xlim[0]], # 初始X坐标
[ylim[0], ylim[1]], # Y坐标范围
color='#FF4444',
linestyle='-',
linewidth=1.2,
alpha=0.8,
zorder=100, # 使用更高的zorder
visible=False
)
self.axMain.add_line(self.crosshair_v)
# 创建水平线
self.crosshair_h = Line2D(
[xlim[0], xlim[1]], # X坐标范围
[ylim[0], ylim[0]], # 初始Y坐标
color='#FF4444',
linestyle='-',
linewidth=1.2,
alpha=0.8,
zorder=100, # 使用更高的zorder
visible=False
)
self.axMain.add_line(self.crosshair_h)
# 创建交叉点圆点 - 使用scatter而不是plot
self.crosshair_point = self.axMain.scatter(
[], [], # 空的初始数据
s=36, # 大小 (6^2)
c='#FF4444', # 颜色
marker='o', # 圆形标记
edgecolors='white', # 边框颜色
linewidths=1.5, # 边框宽度
zorder=101, # 更高的层级
alpha=0.9
)
self.crosshair_point.set_visible(False)
# 创建坐标文本标签
self.crosshair_text = self.axMain.text(
xlim[0], ylim[0], # 初始位置设在坐标轴范围内
'', # 初始文本为空
fontsize=9,
color='#333333',
bbox=dict(
boxstyle='round,pad=0.3',
facecolor='white',
edgecolor='#FF4444',
alpha=0.9
),
zorder=102, # 最高层级
visible=False,
ha='left',
va='bottom'
)
# print("✅ 十字标线组件创建完成")
except Exception as e:
# 如果创建失败,清理并重置引用
self._cleanupCrosshair()
# print(f"❌ 创建十字标线失败: {e}")
def _cleanupCrosshair(self):
"""清理十字标线组件"""
try:
if self.crosshair_v is not None:
self.crosshair_v.remove()
if self.crosshair_h is not None:
self.crosshair_h.remove()
if self.crosshair_point is not None:
self.crosshair_point.remove()
if self.crosshair_text is not None:
self.crosshair_text.remove()
except:
pass
finally:
self.crosshair_v = None
self.crosshair_h = None
self.crosshair_point = None
self.crosshair_text = None
def updateCrosshair(self, x, y):
"""更新十字标线位置 - 安全版本"""
if not self.crosshair_visible:
return
# 确保十字标线已创建
self.createCrosshair()
# 如果十字标线组件不存在,直接返回
if (self.crosshair_v is None or self.crosshair_h is None or
self.crosshair_point is None or self.crosshair_text is None):
# print("十字标线组件不存在,无法更新")
return
try:
# 获取当前坐标轴范围
xlim = self.axMain.get_xlim()
ylim = self.axMain.get_ylim()
# 确保坐标在有效范围内
if not (xlim[0] <= x <= xlim[1] and ylim[0] <= y <= ylim[1]):
return
# 智能吸附到最近的数据点(如果启用)
if self.snap_enabled:
snap_x, snap_y, snap_var_name = self.snapToNearestDataPoint(x, y)
if snap_x is not None and snap_y is not None:
x, y = snap_x, snap_y
# 存储吸附信息,用于显示
self._snapped_var = snap_var_name
else:
self._snapped_var = None
else:
self._snapped_var = None
# 更新垂直线位置
self.crosshair_v.set_data([x, x], [ylim[0], ylim[1]])
self.crosshair_v.set_visible(True)
# 更新水平线位置
self.crosshair_h.set_data([xlim[0], xlim[1]], [y, y])
self.crosshair_h.set_visible(True)
# 更新交叉点圆点位置和样式
self.crosshair_point.set_offsets([[x, y]])
# 根据是否吸附到数据点改变圆点颜色
if hasattr(self, '_snapped_var') and self._snapped_var:
# 吸附状态:使用绿色表示精确定位
self.crosshair_point.set_color('#00AA00')
self.crosshair_point.set_edgecolors('white')
self.crosshair_point.set_sizes([64]) # 稍大一些
else:
# 普通状态:使用红色
self.crosshair_point.set_color('#FF4444')
self.crosshair_point.set_edgecolors('white')
self.crosshair_point.set_sizes([36]) # 正常大小
self.crosshair_point.set_visible(True)
# 更新坐标文本
coord_text = self.formatCoordinateText(x, y)
self.crosshair_text.set_text(coord_text)
# 计算文本位置(避免超出图表边界)
text_x, text_y = self.calculateTextPosition(x, y, xlim, ylim)
self.crosshair_text.set_position((text_x, text_y))
self.crosshair_text.set_visible(True)
# 强制重绘以显示十字标线
self.canvas.draw_idle()
except Exception as e:
# 如果更新失败,静默处理
print(f"更新十字标线失败: {e}")
pass
def snapToNearestDataPoint(self, x, y):
"""智能吸附到最近的数据点 - 多变量增强版"""
if not self.selectedVars or not self.multiVarData:
return None, None, None
min_distance = float('inf')
snap_x, snap_y = None, None
snap_var_name = None
# 获取当前坐标轴范围,用于归一化距离计算
xlim = self.axMain.get_xlim()
ylim = self.axMain.get_ylim()
x_range = xlim[1] - xlim[0]
y_range = ylim[1] - ylim[0]
# 遍历所有选中的变量,找到最近的数据点
for varName in self.selectedVars:
if varName in self.multiVarData:
data = self.multiVarData[varName]
if data['x'] and data['y']:
# 遍历该变量的所有数据点
for i, (data_x, data_y) in enumerate(zip(data['x'], data['y'])):
try:
# 将datetime转换为matplotlib数值
if isinstance(data_x, datetime.datetime):
data_x_num = mdates.date2num(data_x)
else:
data_x_num = data_x
# 计算归一化的欧几里得距离
# 归一化是为了让X轴时间和Y轴数值的距离具有可比性
dx_norm = (data_x_num - x) / x_range if x_range != 0 else 0
dy_norm = (data_y - y) / y_range if y_range != 0 else 0
distance = (dx_norm ** 2 + dy_norm ** 2) ** 0.5
# 使用可配置的吸附阈值
snap_threshold = getattr(self, 'snap_threshold', 0.05) # 默认5%的屏幕距离
if distance < min_distance and distance < snap_threshold:
min_distance = distance
snap_x = data_x_num
snap_y = data_y
snap_var_name = varName
except Exception as e:
# 跳过有问题的数据点
continue
return snap_x, snap_y, snap_var_name
def toggleSnapToDataPoints(self, enabled=None):
"""切换智能吸附功能"""
if enabled is None:
self.snap_enabled = not self.snap_enabled
else:
self.snap_enabled = enabled
print(f"智能吸附功能: {'启用' if self.snap_enabled else '禁用'}")
return self.snap_enabled
def setSnapThreshold(self, threshold):
"""设置吸附阈值0.01-0.2之间)"""
self.snap_threshold = max(0.01, min(0.2, threshold))
print(f"吸附阈值设置为: {self.snap_threshold:.3f}")
return self.snap_threshold
def _delayed_crosshair_draw(self):
"""延迟绘制十字标线,避免频繁更新"""
try:
# 只绘制十字标线相关的元素
if (self.crosshair_v is not None and self.crosshair_v.get_visible()):
self.canvas.draw_idle()
except Exception as e:
# 静默处理异常
pass
def formatCoordinateText(self, x, y):
"""格式化坐标文本显示 - 增强版,显示吸附信息"""
try:
# 格式化时间X轴
if isinstance(x, (int, float)):
# 将matplotlib数值转换为datetime
time_obj = mdates.num2date(x)
time_str = time_obj.strftime('%H:%M:%S')
date_str = time_obj.strftime('%m-%d')
else:
time_str = str(x)
date_str = ""
# 格式化数值Y轴
if isinstance(y, (int, float)):
if abs(y) >= 1000:
value_str = f"{y:.1f}"
elif abs(y) >= 100:
value_str = f"{y:.2f}"
else:
value_str = f"{y:.3f}"
else:
value_str = str(y)
# 构建基础文本
if date_str:
coord_text = f"时间: {date_str} {time_str}\n数值: {value_str}"
else:
coord_text = f"时间: {time_str}\n数值: {value_str}"
# 如果吸附到了数据点,显示变量信息
if hasattr(self, '_snapped_var') and self._snapped_var:
coord_text += f"\n📍 {self._snapped_var}"
return coord_text
except Exception as e:
return f"X: {x:.3f}\nY: {y:.3f}"
def calculateTextPosition(self, x, y, xlim, ylim):
"""计算文本标签的最佳位置,避免超出边界"""
try:
# 计算相对位置
x_range = xlim[1] - xlim[0]
y_range = ylim[1] - ylim[0]
# 默认偏移量(相对于图表大小)
x_offset = x_range * 0.02 # X轴偏移2%
y_offset = y_range * 0.03 # Y轴偏移3%
# 计算初始位置
text_x = x + x_offset
text_y = y + y_offset
# 检查是否超出右边界
if text_x > xlim[1] - x_range * 0.15: # 预留15%空间给文本
text_x = x - x_offset * 3 # 移到左侧
# 检查是否超出上边界
if text_y > ylim[1] - y_range * 0.1: # 预留10%空间给文本
text_y = y - y_offset * 2 # 移到下方
# 确保不超出左边界和下边界
text_x = max(text_x, xlim[0] + x_range * 0.01)
text_y = max(text_y, ylim[0] + y_range * 0.01)
return text_x, text_y
except Exception as e:
# 如果计算失败,返回简单偏移
return x + 0.01, y + 0.01
def showCrosshair(self):
"""显示十字标线"""
self.crosshair_visible = True
self.createCrosshair()
def hideCrosshair(self):
"""隐藏十字标线"""
self.crosshair_visible = False
if self.crosshair_v is not None:
self.crosshair_v.set_visible(False)
if self.crosshair_h is not None:
self.crosshair_h.set_visible(False)
if self.crosshair_point is not None:
self.crosshair_point.set_visible(False)
if self.crosshair_text is not None:
self.crosshair_text.set_visible(False)
# 强制重绘以隐藏十字标线
self.canvas.draw_idle()
def onOverviewXlimChanged(self, eventAx):
"""当概览图范围变化时更新主图"""
if eventAx == self.axOverview:
x1, x2 = self.axOverview.get_xlim()
self.axMain.set_xlim(x1)
self.canvas.draw()
def resetZoom(self):
"""重置到完整视图"""
if self.xData:
self.axMain.set_xlim(min(self.xData), max(self.xData))
self.axOverview.set_xlim(min(self.xData), max(self.xData))
self.canvas.draw()
def zoomBack(self):
# 退到上一个视图
if self.history and self.historyIndex > 0:
self.historyIndex -= 1
self.applyZoom(self.history[self.historyIndex])
self.backBtn.setEnabled(self.historyIndex > 0)
self.forwardBtn.setEnabled(True)
def zoomForward(self):
# 进到下一个视图
if self.history and self.historyIndex < len(self.history) - 1:
self.historyIndex += 1
self.applyZoom(self.history[self.historyIndex])
self.forwardBtn.setEnabled(self.historyIndex < len(self.history) - 1)
self.backBtn.setEnabled(True)
def applyZoom(self, zoomRange):
"""应用指定的缩放范围"""
x1, x2 = zoomRange
self.axMain.set_xlim(x1, x2)
self.canvas.draw()
def enableRectZoom(self):
# 启用框选缩放模式
self.statusLabel.setText("模式: 框选缩放 - 在概览图中拖动选择区域")
def enablePanMode(self):
# 启用平移模式
self.statusLabel.setText("模式: 平移视图 - 在主图中拖动移动视图")
def onProjectChanged(self):
# 项目更改时处理
self.exchangeProject()
def clearAllVars(self):
"""清除趋势图中的所有变量"""
self.selectedVars = []
self.multiVarData = {}
self.varColors = {}
self.axMain.clear()
self.axOverview.clear()
# 重置十字标线引用
self._cleanupCrosshair()
self.axMain.grid(True, alpha=0.3)
self.axOverview.grid(True, alpha=0.3)
self.canvas.draw()
self.statusLabel.setText("趋势图已清除")
self.varNameLabel.setText("当前变量: 无")
self.dataCountLabel.setText("数据点: 0")
self.timeRangeLabel.setText("时间范围: 无数据")
self.resetZoom()
def onOverviewDoubleClick(self, event):
"""双击下方缩略图恢复全局视图"""
if event.dblclick and event.inaxes == self.axOverview:
self.axMain.set_xlim(auto=True)
self.canvas.draw_idle()
def onMouseWheel(self, event):
"""鼠标滚轮缩放主趋势图X轴"""
if event.inaxes == self.axMain and event.xdata is not None:
xlim = self.axMain.get_xlim()
xCenter = event.xdata
# 缩放因子,滚轮向上放大,向下缩小
scaleFactor = 0.8 if event.button == 'up' else 1.25
xLeft = xCenter - (xCenter - xlim[0]) * scaleFactor
xRight = xCenter + (xlim[1] - xCenter) * scaleFactor
self.axMain.set_xlim(xLeft, xRight)
self.canvas.draw_idle()
def onQuickRangeChanged(self, idx):
from PyQt5.QtCore import QDateTime
now = QDateTime.currentDateTime()
if idx == 0: # 最近一天
self.startTimeEdit.setDateTime(now.addDays(-1))
self.endTimeEdit.setDateTime(now)
elif idx == 1: # 最近6小时
self.startTimeEdit.setDateTime(now.addSecs(-6*3600))
self.endTimeEdit.setDateTime(now)
elif idx == 2: # 最近1小时
self.startTimeEdit.setDateTime(now.addSecs(-3600))
self.endTimeEdit.setDateTime(now)
elif idx == 3: # 最近30分钟
self.startTimeEdit.setDateTime(now.addSecs(-1800))
self.endTimeEdit.setDateTime(now)
# idx==4为自定义不做处理
# 自动触发查询
if idx != 4:
self.onTimeRangeQuery()
def performSearch(self, searchText):
"""统一搜索接口,供顶部搜索栏调用"""
try:
self.filterVarList(searchText)
return True
except Exception as e:
print(f"趋势界面搜索失败: {e}")
return False
def getSearchPlaceholder(self):
"""获取搜索占位符文本"""
return "搜索历史变量..."
# 使用示例
if __name__ == "__main__":
app = QApplication(sys.argv)
window = TrendWidgets()
window.show()
sys.exit(app.exec_())