From 38fc80f819b22ddf7cb7affa24460b8cb770c770 Mon Sep 17 00:00:00 2001 From: zcwBit Date: Sun, 20 Jul 2025 00:28:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B6=8B=E5=8A=BF=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E6=98=BE=E7=A4=BA=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Static/Main.qss | 194 +++++++++ UI/TrendManage/TrendWidget.py | 718 +++++++++++++++++++--------------- 2 files changed, 607 insertions(+), 305 deletions(-) diff --git a/Static/Main.qss b/Static/Main.qss index 2017598..83b1999 100644 --- a/Static/Main.qss +++ b/Static/Main.qss @@ -909,6 +909,200 @@ QListView#trendListView::item{ } +/* 历史趋势界面样式 */ +QWidget#trendMainWidget { + background-color: #F5F5F5; + border-radius: 8px; +} + +QGroupBox#trendVariableListGroup { + font: bold 16px "PingFangSC-Medium"; + color: #2277EF; + border: 2px solid #E0E0E0; + border-radius: 8px; + margin-top: 12px; + padding-top: 8px; + background-color: #FFFFFF; +} + +QGroupBox#trendVariableListGroup::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 8px 0 8px; + background-color: #FFFFFF; +} + +QGroupBox#trendTimeGroupBox, QGroupBox#trendButtonGroupBox { + font: bold 14px "PingFangSC-Medium"; + color: #555555; + border: 1px solid #D0D0D0; + border-radius: 6px; + margin-top: 10px; + padding-top: 6px; + background-color: #FAFAFA; +} + +QGroupBox#trendTimeGroupBox::title, QGroupBox#trendButtonGroupBox::title { + subcontrol-origin: margin; + left: 8px; + padding: 0 6px 0 6px; + background-color: #FAFAFA; +} + +QLineEdit#trendSearchInput { + border: 2px solid #E0E0E0; + border-radius: 6px; + padding: 8px 12px; + font: 14px "PingFangSC-Regular"; + background-color: #FFFFFF; + color: #333333; +} + +QLineEdit#trendSearchInput:focus { + border-color: #2277EF; + background-color: #F8F9FF; +} + +QListWidget#trendVarListWidget { + border: 1px solid #E0E0E0; + border-radius: 6px; + background-color: #FFFFFF; + font: 14px "PingFangSC-Regular"; + selection-background-color: #E3F2FD; + selection-color: #1976D2; +} + +QListWidget#trendVarListWidget::item { + padding: 8px 12px; + border-bottom: 1px solid #F0F0F0; +} + +QListWidget#trendVarListWidget::item:hover { + background-color: #F5F5F5; +} + +QListWidget#trendVarListWidget::item:selected { + background-color: #E3F2FD; + color: #1976D2; +} + +QComboBox#trendQuickRangeCombo { + border: 1px solid #D0D0D0; + border-radius: 4px; + padding: 6px 8px; + font: 13px "PingFangSC-Regular"; + background-color: #FFFFFF; + min-width: 120px; +} + +QComboBox#trendQuickRangeCombo::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 20px; + border: none; +} + +QComboBox#trendQuickRangeCombo::down-arrow { + image: url(./Static/down.png); + width: 12px; + height: 12px; +} + +QDateTimeEdit#trendStartTimeEdit, QDateTimeEdit#trendEndTimeEdit { + border: 1px solid #D0D0D0; + border-radius: 4px; + padding: 6px 8px; + font: 13px "PingFangSC-Regular"; + background-color: #FFFFFF; +} + +QDateTimeEdit#trendStartTimeEdit:focus, QDateTimeEdit#trendEndTimeEdit:focus { + border-color: #2277EF; +} + +QToolButton#trendQueryBtn, QToolButton#trendRefreshBtn, +QToolButton#trendAddBtn, QToolButton#trendClearBtn { + border: 2px solid #2277EF; + border-radius: 6px; + padding: 8px 16px; + font: bold 14px "PingFangSC-Medium"; + color: #2277EF; + background-color: #FFFFFF; + min-height: 32px; +} + +QToolButton#trendQueryBtn:hover, QToolButton#trendRefreshBtn:hover, +QToolButton#trendAddBtn:hover, QToolButton#trendClearBtn:hover { + background-color: #2277EF; + color: #FFFFFF; +} + +QToolButton#trendQueryBtn:pressed, QToolButton#trendRefreshBtn:pressed, +QToolButton#trendAddBtn:pressed, QToolButton#trendClearBtn:pressed { + background-color: #1A5FCC; + border-color: #1A5FCC; +} + +QWidget#trendViewerGroup { + background-color: #FFFFFF; + border-radius: 8px; +} + +QGroupBox#trendChartGroup { + font: bold 16px "PingFangSC-Medium"; + color: #2277EF; + border: 2px solid #E0E0E0; + border-radius: 8px; + margin-top: 12px; + padding-top: 8px; + background-color: #FFFFFF; +} + +QGroupBox#trendChartGroup::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 8px 0 8px; + background-color: #FFFFFF; +} + +QGroupBox#trendInfoGroup { + font: bold 14px "PingFangSC-Medium"; + color: #555555; + border: 1px solid #E0E0E0; + border-radius: 6px; + margin-top: 8px; + padding-top: 6px; + background-color: #F8F9FA; +} + +QGroupBox#trendInfoGroup::title { + subcontrol-origin: margin; + left: 8px; + padding: 0 6px 0 6px; + background-color: #F8F9FA; +} + +QLabel#trendVarNameLabel, QLabel#trendDataCountLabel, +QLabel#trendTimeRangeLabel, QLabel#trendStatusLabel { + font: 13px "PingFangSC-Regular"; + color: #666666; + padding: 4px 8px; +} + +QLabel#trendStatusLabel { + color: #2277EF; + font-weight: bold; +} + +QLabel#trendInfoBubble { + background: rgba(255, 255, 255, 0.95); + border: 2px solid #2277EF; + border-radius: 8px; + padding: 8px 12px; + font: 12px "Consolas", "Monaco", monospace; + color: #333333; +} + QMessageBox { background-color: #ffffff; border-radius: 16px; diff --git a/UI/TrendManage/TrendWidget.py b/UI/TrendManage/TrendWidget.py index eb36462..9bdcdc3 100644 --- a/UI/TrendManage/TrendWidget.py +++ b/UI/TrendManage/TrendWidget.py @@ -31,8 +31,8 @@ class TrendWidgets(QWidget): super().__init__() self.setWindowTitle("历史趋势浏览器") self.setMinimumSize(120, 120) - # 设置窗口标志,禁用窗口拖拽 - self.setWindowFlags(QtCore.Qt.WindowType.Window) + self.setObjectName("trendMainWidget") + # 初始化数据 self.historyDB = None self.currentVarName = None @@ -45,111 +45,178 @@ class TrendWidgets(QWidget): # 初始化历史缩放记录 self.history = [] self.historyIndex = 0 - # 主布局 + + self.initializeUI() + self.connectSignals() + Globals.setValue('HistoryWidget', self) + + def initializeUI(self): + """初始化用户界面""" mainLayout = QHBoxLayout(self) - mainLayout.setSpacing(10) - mainLayout.setContentsMargins(10, 10, 10, 10) - # 左侧变量列表区域 + mainLayout.setSpacing(8) + mainLayout.setContentsMargins(8, 8, 8, 8) + + # 左侧变量列表区域 (占25%宽度) self.createVariableListPanel() - mainLayout.addWidget(self.variableListGroup, 1) # 1份宽度 - # 右侧趋势图区域 + mainLayout.addWidget(self.variableListGroup, 1) + + # 右侧趋势图区域 (占75%宽度) self.trendViewerGroup = QWidget() + self.trendViewerGroup.setObjectName("trendViewerGroup") self.createTrendViewerPanel() - mainLayout.addWidget(self.trendViewerGroup, 6) # 6份宽度 - # 连接信号 - self.connectSignals() - Globals.setValue('HistoryWidget', self) + mainLayout.addWidget(self.trendViewerGroup, 10) def createVariableListPanel(self): - # 创建变量列表面板 + """创建变量列表面板""" self.variableListGroup = QGroupBox("变量列表") + self.variableListGroup.setObjectName("trendVariableListGroup") layout = QVBoxLayout(self.variableListGroup) - # 搜索框单独一行 - searchInputLayout = QHBoxLayout() + layout.setSpacing(8) + layout.setContentsMargins(8, 8, 8, 8) + + # 搜索框 self.searchInput = QLineEdit() + self.searchInput.setObjectName("trendSearchInput") self.searchInput.setPlaceholderText("搜索变量...") self.searchInput.textChanged.connect(self.filterVarList) - self.searchInput.setMinimumWidth(220) - searchInputLayout.addWidget(self.searchInput) - layout.addLayout(searchInputLayout) + layout.addWidget(self.searchInput) + # 变量列表 self.varListWidget = QListWidget() + self.varListWidget.setObjectName("trendVarListWidget") self.varListWidget.itemDoubleClicked.connect(self.showVarTrend) self.varListWidget.itemSelectionChanged.connect(self.onVarSelected) layout.addWidget(self.varListWidget) - # 时间选择+便捷查询区域 - searchLayout = QHBoxLayout() + + # 时间选择区域 + 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) - searchLayout.addWidget(self.startTimeEdit) - searchLayout.addWidget(self.endTimeEdit) - self.quickRangeCombo = QtWidgets.QComboBox() - self.quickRangeCombo.addItems(["最近一天", "最近6小时", "最近1小时", "最近30分钟", "自定义"]) - self.quickRangeCombo.currentIndexChanged.connect(self.onQuickRangeChanged) - searchLayout.addWidget(self.quickRangeCombo) + + timeEditLayout.addWidget(QLabel("开始时间:")) + timeEditLayout.addWidget(self.startTimeEdit) + timeEditLayout.addWidget(QLabel("结束时间:")) + timeEditLayout.addWidget(self.endTimeEdit) + + timeLayout.addLayout(timeEditLayout) + layout.addWidget(timeGroupBox) + + # 按钮区域 + buttonGroupBox = QGroupBox("操作") + buttonGroupBox.setObjectName("trendButtonGroupBox") + buttonLayout = QVBoxLayout(buttonGroupBox) + buttonLayout.setSpacing(6) + + # 第一行按钮 + firstRowLayout = QHBoxLayout() + firstRowLayout.setSpacing(6) + self.queryBtn = QToolButton() - self.queryBtn.setText("查询") + self.queryBtn.setObjectName("trendQueryBtn") + self.queryBtn.setText("查询数据") self.queryBtn.setToolTip("按时间范围查询变量数据") self.queryBtn.clicked.connect(self.onTimeRangeQuery) - searchLayout.addWidget(self.queryBtn) - layout.addLayout(searchLayout) - # 按钮区域 - buttonLayout = QHBoxLayout() - # 刷新按钮 - refreshBtn = QToolButton() - refreshBtn.setText("刷新列表") - refreshBtn.clicked.connect(self.refreshVarList) - buttonLayout.addWidget(refreshBtn) - # 添加到趋势图按钮 - addToTrendBtn = QToolButton() - addToTrendBtn.setText("添加到趋势图") - addToTrendBtn.setToolTip("将选中的变量添加到趋势图") - addToTrendBtn.clicked.connect(self.addSelectedVarsToTrend) - buttonLayout.addWidget(addToTrendBtn) - # 清除所有变量按钮 - clearAllBtn = QToolButton() - clearAllBtn.setText("清除所有") - clearAllBtn.setToolTip("清除趋势图中的所有变量") - clearAllBtn.clicked.connect(self.clearAllVars) - buttonLayout.addWidget(clearAllBtn) - layout.addLayout(buttonLayout) - self.variableListGroup.setLayout(layout) + firstRowLayout.addWidget(self.queryBtn) + + self.refreshBtn = QToolButton() + self.refreshBtn.setObjectName("trendRefreshBtn") + self.refreshBtn.setText("刷新列表") + self.refreshBtn.clicked.connect(self.refreshVarList) + firstRowLayout.addWidget(self.refreshBtn) + + buttonLayout.addLayout(firstRowLayout) + + # 第二行按钮 + secondRowLayout = QHBoxLayout() + secondRowLayout.setSpacing(6) + + self.addToTrendBtn = QToolButton() + self.addToTrendBtn.setObjectName("trendAddBtn") + self.addToTrendBtn.setText("添加变量") + 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.setToolTip("清除趋势图中的所有变量") + self.clearAllBtn.clicked.connect(self.clearAllVars) + secondRowLayout.addWidget(self.clearAllBtn) + + buttonLayout.addLayout(secondRowLayout) + + layout.addWidget(buttonGroupBox) + # 记录当前时间范围 - self._query_time_range = (self.startTimeEdit.dateTime().toPyDateTime(), self.endTimeEdit.dateTime().toPyDateTime()) + self._queryTimeRange = (self.startTimeEdit.dateTime().toPyDateTime(), self.endTimeEdit.dateTime().toPyDateTime()) def createTrendViewerPanel(self): - # 创建趋势图查看器面板 + """创建趋势图查看器面板""" layout = QVBoxLayout(self.trendViewerGroup) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(5) - # 创建包含图表的组框 - chartGroup = QGroupBox() + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(8) + + # 图表区域 + chartGroup = QGroupBox("趋势图表") + chartGroup.setObjectName("trendChartGroup") chartLayout = QVBoxLayout(chartGroup) - chartLayout.setContentsMargins(5, 5, 5, 5) - # 创建matplotlib图表,设置主图和缩略图比例为6:1 + chartLayout.setContentsMargins(8, 8, 8, 8) + + # 创建matplotlib图表 self.figure = Figure(figsize=(12, 8), dpi=100) gs = GridSpec(7, 1, figure=self.figure) - self.ax_main = self.figure.add_subplot(gs[:6, 0]) # 主图,占6份 - self.ax_overview = self.figure.add_subplot(gs[6, 0]) # 缩略图,占1份 + 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, 20) - # 底部信息栏(含状态显示) - infoLayout = QHBoxLayout() - infoLayout.setContentsMargins(5, 5, 5, 5) + 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.setFont(QtGui.QFont("Arial", 9)) - self.statusLabel.setStyleSheet("color: #555; padding: 2px;") + self.statusLabel.setObjectName("trendStatusLabel") + infoLayout.addWidget(self.varNameLabel) infoLayout.addStretch() infoLayout.addWidget(self.dataCountLabel) @@ -157,44 +224,54 @@ class TrendWidgets(QWidget): infoLayout.addWidget(self.timeRangeLabel) infoLayout.addStretch() infoLayout.addWidget(self.statusLabel) - chartLayout.addLayout(infoLayout, 1) - layout.addWidget(chartGroup) - # 悬浮信息气泡label + + 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.setStyleSheet("background:rgba(255,255,220,0.95); border:1px solid #aaa; border-radius:4px; padding:4px; color:#222;") - self.infoBubble.setFont(QtGui.QFont("Consolas", 10)) + self.infoBubble.setObjectName("trendInfoBubble") self.infoBubble.setVisible(False) - # 连接鼠标事件,支持拖拽平移 - self.canvas.mpl_connect('motion_notify_event', self.on_mouse_move) - self.canvas.mpl_connect('axes_leave_event', self.on_mouse_leave) - self.canvas.mpl_connect('scroll_event', self.on_mouse_wheel) - self.canvas.mpl_connect('button_press_event', self.on_mouse_press) - self.canvas.mpl_connect('button_release_event', self.on_mouse_release) + + # 连接鼠标事件 + self.canvas.mpl_connect('motion_notify_event', self.onMouseMove) + self.canvas.mpl_connect('axes_leave_event', self.onMouseLeave) + 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._is_panning = False - self._pan_start_x = None - self._pan_start_xlim = None - # 初始化选择器 - self.selector = None - self.is_selecting = False - self.zoom_start = None + self._isPanning = False + self._panStartX = None + self._panStartXlim = None + # 添加SpanSelector用于区间选择 self.span = SpanSelector( - self.ax_overview, self.on_select, 'horizontal', + self.axOverview, self.onSelect, 'horizontal', useblit=True, interactive=True, props=dict(alpha=0.3, facecolor='orange') ) + # 绑定双击事件恢复全局视图 - self.canvas.mpl_connect('button_press_event', self.on_overview_double_click) - self._selected_range = None # 记录选中区间 - # 设置下方缩略图x轴显示更多时间内容 + self.canvas.mpl_connect('button_press_event', self.onOverviewDoubleClick) + self._selectedRange = None # 记录选中区间 + + # 设置缩略图x轴显示格式 import matplotlib.dates as mdates - from matplotlib.ticker import MaxNLocator - self.ax_overview.xaxis.set_major_locator(mdates.AutoDateLocator()) - self.ax_overview.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M')) + self.axOverview.xaxis.set_major_locator(mdates.AutoDateLocator()) + self.axOverview.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M')) def connectSignals(self): - # 连接所有信号 - self.ax_overview.callbacks.connect('xlim_changed', self.on_overview_xlim_changed) + """连接所有信号""" + self.axOverview.callbacks.connect('xlim_changed', self.onOverviewXlimChanged) def exchangeProject(self): # 项目切换时调用 @@ -256,153 +333,184 @@ class TrendWidgets(QWidget): self.statusLabel.setText("选中的变量已在趋势图中") def onTimeRangeQuery(self): - # 查询按钮点击,记录时间范围 - self._query_time_range = ( + """查询按钮点击,记录时间范围""" + self._queryTimeRange = ( self.startTimeEdit.dateTime().toPyDateTime(), self.endTimeEdit.dateTime().toPyDateTime() ) - import datetime + # 重新加载所有已选变量的数据 for varName in self.selectedVars: - start_time, end_time = self._query_time_range - if isinstance(start_time, datetime.datetime): - start_time = start_time.isoformat() - if isinstance(end_time, datetime.datetime): - end_time = end_time.isoformat() - if self.historyDB and hasattr(self.historyDB, 'queryVarHistory'): - data, timeList = self.historyDB.queryVarHistory(varName, start_time, end_time) - xData = [] - for t in timeList: - dt = None - if isinstance(t, datetime.datetime): - dt = t - elif hasattr(t, 'to_pydatetime'): - dt = t.to_pydatetime() - elif isinstance(t, str): - try: - dt = datetime.datetime.fromisoformat(t) - except: - continue - if dt: - xData.append(dt) - self.multiVarData[varName] = {'x': xData, 'y': data} + self.loadVarData(varName) + self.updateMultiVarChart() - self.statusLabel.setText(f"已设置时间范围: {self._query_time_range[0].strftime('%Y-%m-%d %H:%M:%S')} ~ {self._query_time_range[1].strftime('%Y-%m-%d %H:%M:%S')}") + 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} 数据...") - import datetime - if hasattr(self, '_query_time_range') and self._query_time_range: - start_time, end_time = self._query_time_range + + # 获取时间范围 + if hasattr(self, '_queryTimeRange') and self._queryTimeRange: + startTime, endTime = self._queryTimeRange else: - start_time = None - end_time = None + startTime = None + endTime = None + # 从数据库获取数据 if self.historyDB and hasattr(self.historyDB, 'queryVarHistory'): - print(start_time, end_time) - data, timeList = self.historyDB.queryVarHistory(varName, start_time, end_time) - # 直接使用数据库返回的时间,不做任何时区转换 - 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()) - # 存储数据 - self.multiVarData[varName] = {'x': xData, 'y': data} - # 分配颜色 - if varName not in self.varColors: - colors = ['blue', 'red', 'green', 'cyan', 'magenta', 'yellow', 'black'] - colorIndex = len(self.varColors) % len(colors) - self.varColors[varName] = colors[colorIndex] + 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 - # 更新多变量图表 - self.ax_main.clear() - self.ax_overview.clear() + from matplotlib.ticker import MaxNLocator + + # 清空图表 + self.axMain.clear() + self.axOverview.clear() + # 设置网格 - self.ax_main.grid(True, alpha=0.3) - self.ax_overview.grid(True, alpha=0.3) - # 复用数据处理逻辑,提升性能 + 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']: - # 处理断线数据(复用逻辑) - x_processed, y_processed = self._process_break_line_data(data['x'], data['y']) + # 处理断线数据 + xProcessed, yProcessed = self.processBreakLineData(data['x'], data['y']) # 缩略图:显示全量数据 - self.ax_overview.plot(x_processed, y_processed, color=self.varColors[varName], linewidth=1, alpha=0.7) + self.axOverview.plot(xProcessed, yProcessed, + color=self.varColors[varName], + linewidth=1, alpha=0.7) # 主图:根据选中区间显示数据 - if self._selected_range is not None: - xlim = self._selected_range - # 筛选区间数据 - x_num = [mdates.date2num(xx) for xx in x_processed if xx is not None] - x_filtered, y_filtered = [], [] - for xx, yy in zip(x_processed, y_processed): - if xx is not None: - xn = mdates.date2num(xx) - if xlim[0] <= xn <= xlim[1]: - x_filtered.append(xx) - y_filtered.append(yy) - x_main, y_main = x_filtered, y_filtered - else: - # 没有选择范围时,主图显示全部数据 - x_main, y_main = x_processed, y_processed - - self.ax_main.plot(x_main, y_main, color=self.varColors[varName], linewidth=2, marker='o', markersize=4, label=varName) - # 设置时间格式 - self.ax_main.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S')) - # 增加主图和缩略图的坐标轴刻度数量,并显示完整日期时间 + 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.ax_main.xaxis.set_major_locator(mdates.AutoDateLocator()) - self.ax_main.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d %H:%M:%S')) - self.ax_main.yaxis.set_major_locator(MaxNLocator(nbins=16, prune=None)) - self.ax_overview.xaxis.set_major_locator(mdates.AutoDateLocator()) - self.ax_overview.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d %H:%M:%S')) - self.ax_overview.yaxis.set_major_locator(MaxNLocator(nbins=16, prune=None)) - # 刻度数量更多 - self.ax_main.xaxis.set_major_locator(MaxNLocator(nbins=16, prune=None)) - self.ax_overview.xaxis.set_major_locator(MaxNLocator(nbins=16, prune=None)) + + # 设置时间格式 + 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.ax_main.set_title('历史趋势图', fontsize=12, fontweight='bold') - self.ax_main.set_ylabel('数值') - self.ax_overview.set_xlabel('时间') - self.ax_overview.set_ylabel('数值') + 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.ax_main.legend(loc='upper right') + self.axMain.legend(loc='upper right', fontsize=10) + # 自动调整布局 self.figure.tight_layout() - # 刷新画布 - self.canvas.draw() - def _process_break_line_data(self, x_raw, y_raw): - """处理断线数据:间隔大于2分钟的点不连线""" + def processBreakLineDataOld(self, xRaw, yRaw): + """处理断线数据:间隔大于2分钟的点不连线(旧版本兼容)""" x, y = [], [] - prev_time = None - for xi, yi in zip(x_raw, y_raw): - if prev_time is not None and (xi - prev_time).total_seconds() > 60: + 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) - prev_time = xi + prevTime = xi return x, y def filterVarList(self, text): @@ -460,17 +568,17 @@ class TrendWidgets(QWidget): self.statusLabel.setText(f"{varName} 趋势已显示") def updateChart(self): - # 用当前数据更新图表 - self.ax_main.clear() - self.ax_overview.clear() + """用当前数据更新图表""" + self.axMain.clear() + self.axOverview.clear() # 设置网格 - self.ax_main.grid(True, alpha=0.3) - self.ax_overview.grid(True, alpha=0.3) + self.axMain.grid(True, alpha=0.3) + self.axOverview.grid(True, alpha=0.3) # 绘制数据 if self.xData and self.yData: - self.ax_main.plot(self.xData, self.yData, + self.axMain.plot(self.xData, self.yData, color='blue', linewidth=2, marker='o', @@ -479,26 +587,26 @@ class TrendWidgets(QWidget): # 概览图(降采样) step = max(1, len(self.xData) // 10) - x_overview = self.xData[::step] - y_overview = self.yData[::step] - self.ax_overview.plot(x_overview, y_overview, + xOverview = self.xData[::step] + yOverview = self.yData[::step] + self.axOverview.plot(xOverview, yOverview, color='blue', linewidth=1, alpha=0.7) # 设置时间格式 - self.ax_main.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S')) - self.ax_overview.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S')) + self.axMain.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S')) + self.axOverview.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S')) # 设置标签 - self.ax_main.set_title('历史趋势图', fontsize=12, fontweight='bold') - self.ax_main.set_ylabel('数值') - self.ax_overview.set_xlabel('时间') - self.ax_overview.set_ylabel('数值') + self.axMain.set_title('历史趋势图', fontsize=12, fontweight='bold') + self.axMain.set_ylabel('数值') + self.axOverview.set_xlabel('时间') + self.axOverview.set_ylabel('数值') # 添加图例 if self.currentVarName: - self.ax_main.legend(loc='upper right') + self.axMain.legend(loc='upper right') # 自动调整布局 self.figure.tight_layout() @@ -506,101 +614,106 @@ class TrendWidgets(QWidget): # 刷新画布 self.canvas.draw() - def on_mouse_press(self, event): - # 鼠标左键按下,准备平移 - if event.inaxes == self.ax_main and event.button == 1 and event.xdata is not None: - self._is_panning = True - self._pan_start_x = event.xdata - self._pan_start_xlim = self.ax_main.get_xlim() + 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 on_mouse_release(self, event): - # 鼠标左键松开,结束平移 - if self._is_panning: - self._is_panning = False - self._pan_start_x = None - self._pan_start_xlim = None - # 拖拽结束后再重绘 + def onMouseRelease(self, event): + """鼠标释放事件""" + if self._isPanning: + self._isPanning = False + self._panStartX = None + self._panStartXlim = None self.canvas.draw_idle() - def on_mouse_move(self, event): - # 鼠标移动事件,显示悬浮气泡+平移主图 - if self._is_panning and event.inaxes == self.ax_main and event.xdata is not None and self._pan_start_x is not None: - dx = event.xdata - self._pan_start_x - xlim0, xlim1 = self._pan_start_xlim - self.ax_main.set_xlim(xlim0 - dx, xlim1 - dx) - # 拖拽时不重绘,提高流畅度 - # self.canvas.draw_idle() - # 悬浮气泡逻辑(不变) - if event.inaxes == self.ax_main and event.xdata is not None and event.ydata is not None: - info_text = 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']: - closest_idx = self.find_closest_point(data['x'], event.xdata) - if closest_idx is not None and closest_idx < len(data['y']): - value = data['y'][closest_idx] - info_text += f"{varName}: {value:.3f}
" - self.infoBubble.setText(f"{info_text}") - 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 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.showInfoBubble(event) else: self.infoBubble.setVisible(False) - # self.canvas.draw() # 性能优化:去除重绘 - def on_mouse_leave(self, event): - # 鼠标离开主图时隐藏气泡 + def onMouseLeave(self, event): + """鼠标离开事件""" 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 find_closest_point(self, x_data, target_x): - # 找到最接近目标X值的数据点索引 - if not x_data: + def findClosestPoint(self, xData, targetX): + """找到最接近目标X值的数据点索引""" + if not xData: return None # 将datetime转换为matplotlib日期 - if isinstance(x_data[0], datetime.datetime): - x_nums = mdates.date2num(x_data) + if isinstance(xData[0], datetime.datetime): + xNums = mdates.date2num(xData) else: - x_nums = x_data + xNums = xData # 使用二分查找 left = 0 - right = len(x_nums) - 1 + right = len(xNums) - 1 while left <= right: mid = (left + right) // 2 - if x_nums[mid] == target_x: + if xNums[mid] == targetX: return mid - elif x_nums[mid] < target_x: + elif xNums[mid] < targetX: left = mid + 1 else: right = mid - 1 + # 找到最接近的点 - if left >= len(x_nums): - return len(x_nums) - 1 + if left >= len(xNums): + return len(xNums) - 1 elif right < 0: return 0 else: - if abs(x_nums[left] - target_x) < abs(x_nums[right] - target_x): + if abs(xNums[left] - targetX) < abs(xNums[right] - targetX): return left else: return right - def on_overview_xlim_changed(self, event_ax): - # 当概览图范围变化时更新主图 - if event_ax == self.ax_overview: - x1, x2 = self.ax_overview.get_xlim() - self.ax_main.set_xlim(x1) + 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.ax_main.set_xlim(min(self.xData), max(self.xData)) - self.ax_overview.set_xlim(min(self.xData), max(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): @@ -618,10 +731,10 @@ class TrendWidgets(QWidget): self.applyZoom(self.history[self.historyIndex]) self.forwardBtn.setEnabled(self.historyIndex < len(self.history) - 1) self.backBtn.setEnabled(True) - def applyZoom(self, zoom_range): - # 应用指定的缩放范围 - x1, x2 = zoom_range - self.ax_main.set_xlim(x1, x2) + def applyZoom(self, zoomRange): + """应用指定的缩放范围""" + x1, x2 = zoomRange + self.axMain.set_xlim(x1, x2) self.canvas.draw() def enableRectZoom(self): @@ -635,14 +748,14 @@ class TrendWidgets(QWidget): # 项目更改时处理 self.exchangeProject() def clearAllVars(self): - # 清除趋势图中的所有变量 + """清除趋势图中的所有变量""" self.selectedVars = [] self.multiVarData = {} self.varColors = {} - self.ax_main.clear() - self.ax_overview.clear() - self.ax_main.grid(True, alpha=0.3) - self.ax_overview.grid(True, alpha=0.3) + self.axMain.clear() + self.axOverview.clear() + self.axMain.grid(True, alpha=0.3) + self.axOverview.grid(True, alpha=0.3) self.canvas.draw() self.statusLabel.setText("趋势图已清除") self.varNameLabel.setText("当前变量: 无") @@ -650,27 +763,22 @@ class TrendWidgets(QWidget): self.timeRangeLabel.setText("时间范围: 无数据") self.resetZoom() - def on_select(self, xmin, xmax): - # SpanSelector回调,记录选中区间并更新主趋势图 - self._selected_range = (xmin, xmax) - self.updateMultiVarChart() - - def on_overview_double_click(self, event): - # 双击下方缩略图恢复全局视图 - if event.dblclick and event.inaxes == self.ax_overview: - self.ax_main.set_xlim(auto=True) + def onOverviewDoubleClick(self, event): + """双击下方缩略图恢复全局视图""" + if event.dblclick and event.inaxes == self.axOverview: + self.axMain.set_xlim(auto=True) self.canvas.draw_idle() - def on_mouse_wheel(self, event): - # 鼠标滚轮缩放主趋势图X轴 - if event.inaxes == self.ax_main and event.xdata is not None: - xlim = self.ax_main.get_xlim() - x_center = event.xdata + def onMouseWheel(self, event): + """鼠标滚轮缩放主趋势图X轴""" + if event.inaxes == self.axMain and event.xdata is not None: + xlim = self.axMain.get_xlim() + xCenter = event.xdata # 缩放因子,滚轮向上放大,向下缩小 - scale_factor = 0.8 if event.button == 'up' else 1.25 - x_left = x_center - (x_center - xlim[0]) * scale_factor - x_right = x_center + (xlim[1] - x_center) * scale_factor - self.ax_main.set_xlim(x_left, x_right) + 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):