|
|
|
|
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._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)
|
|
|
|
|
|
|
|
|
|
def exchangeProject(self):
|
|
|
|
|
# 项目切换时调用
|
|
|
|
|
# self.historyDB = None
|
|
|
|
|
self.historyDB = Globals.getValue('historyDBManage')
|
|
|
|
|
self.refreshVarList()
|
|
|
|
|
|
|
|
|
|
def refreshVarList(self):
|
|
|
|
|
# 刷新变量列表
|
|
|
|
|
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())
|
|
|
|
|
# 添加排序后的变量到列表
|
|
|
|
|
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')}<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_())
|