from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import QSize, Qt, QTimer, QEvent 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 from utils import Globals import qtawesome 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 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 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) # 变量列表 self.varListWidget = QListWidget() self.varListWidget.setObjectName("trendVarListWidget") 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.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) def exchangeProject(self): # 项目切换时调用 self.historyDB = Globals.getValue('historyDBManage') self.refreshVarList() def refreshVarList(self): # 刷新变量列表 self.varListWidget.clear() self.statusLabel.setText("正在加载变量列表...") if not self.historyDB or not hasattr(self.historyDB, 'getAllVarNames'): self.statusLabel.setText("未连接历史数据库,无法获取变量列表") return varNames = self.historyDB.getAllVarNames() # print(varNames) # 对变量名称进行排序 varNames.sort(key=lambda x: x.lower()) # 添加排序后的变量到列表 for name in varNames: item = QListWidgetItem(name) 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("请先选择要添加的变量") 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() ) # 重新加载所有已选变量的数据 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中插入None,x继续插入当前时间戳 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')}
" 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}
" self.infoBubble.setText(f"{infoText}") 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 # 可选:吸附到最近的数据点 snap_x, snap_y = self.snapToNearestDataPoint(x, y) if snap_x is not None and snap_y is not None: x, y = snap_x, snap_y # 更新垂直线位置 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]]) 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: return None, None min_distance = float('inf') snap_x, snap_y = None, None # 遍历所有选中的变量,找到最近的数据点 for varName in self.selectedVars: if varName in self.multiVarData: data = self.multiVarData[varName] if data['x'] and data['y']: # 找到最接近的X坐标点 closest_idx = self.findClosestPoint(data['x'], x) if closest_idx is not None and closest_idx < len(data['y']): data_x = data['x'][closest_idx] data_y = data['y'][closest_idx] # 将datetime转换为matplotlib数值 if isinstance(data_x, datetime.datetime): data_x_num = mdates.date2num(data_x) else: data_x_num = data_x # 计算距离(在屏幕坐标系中) distance = abs(data_x_num - x) # 只有距离足够近才吸附(避免过度吸附) if distance < min_distance and distance < 0.01: # 可调整吸附阈值 min_distance = distance snap_x = data_x_num snap_y = data_y return snap_x, snap_y 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') else: time_str = str(x) # 格式化数值(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) return f"时间: {time_str}\n数值: {value_str}" 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_())