【QT】PySide6 数据可视化折线图

一、项目介绍

本项目将通过PySide6构建一个可以显示数据折线图的可视化程序,其中,数据来源时美国地质调查局(US Geological Survey)上公开的一小时地震震级数据。

可以通过链接进行下载。


二、实现步骤

本项目的实现步骤可以概括为:

  • 读取数据
  • 数据处理
  • 创建主窗口
  • 添加控件
  • 绘制图形并显示、

预期结果如下图所示:


三、实现

1️⃣ 读取数据

这里我们借助Pandas对CSV文件进行读取。argparse模块主要用于参数控制,具体可见这篇文章

创建一个新文档main.py,接下来都是一些简单的操作,就不做赘述了。

import argparse
import pandas as pd

def read_data(file):
    return pd.read_csv(file)

if __name__ == '__main__':
    options=argparse.ArgumentParser()
    options.add_argument("-f","--file",type=str,required=True)
    args=options.parse_args()
    data=read_data(args.file)
    print(data)

我们可以通过终端输入python main.py -f "YourPath"来查看数据读取情况。


2️⃣ 数据清洗

我们在这部,需要将数据中的日期转换为Qt类型,并且确保数据的完整性、准确性。

值得注意的是,数据中的日期是UTC标准(如: 2018-12-11T21:14:44,682Z),我们可以比较容易地转换为QDateTime类型。

这个QtDateTime位于QtCore模块,从QtCore中将其导入:

from PySide6.QtCore import QDateTime,QTimeZone

接着,通过QtDateTime().fromString( time , format )进行转换:

def transform_date(utc,timezone=None):
    # 日期转换
    utc_fmt="yyyy-MM-ddTHH:mm:ss.zzzZ"
    new_date=QDateTime().fromString(utc,utc_fmt)
    if timezone:
        new_date.setTimeZone(timezone)
    return new_date

我们对读取数据方法进行一定的修改,首先是移除错误的震级数据:

    data=pd.read_csv(file)
    data=data.drop(data[data['mag']<0].index)
    magnitudes=data['mag']

然后,设置本地的时区:

    # 时区设置
    timezone = QTimeZone(QTimeZone.systemTimeZone()) # "Asia/Shanghai"
    # 时间转换
    times=data['time'].apply(lambda x:transform_date(x,timezone))
    return times,magnitudes

虽然不该在这篇文档中提,但还是提一嘴:

Pandas快速对某一类操作:

Series.apply(lambda x:func(x)) # apply(func)但是如果要输入参数,可以这样写: .apply(lambda x:func(x))

好了,此时我们的read_data方法应该长这样:

def read_data(file):
    # 读取数据
    data=pd.read_csv(file)
    # 处理震级
    data=data.drop(data[data['mag']<0].index)
    magnitudes=data['mag']
    # 时区设置
    timezone = QTimeZone(QTimeZone.systemTimeZone()) # "Asia/Shanghai"
    # 时间转换
    times=data['time'].apply(lambda x:transform_date(x,timezone))
    return times,magnitudes

3️⃣ 创建主窗体

好啦,终于要进入我们的核心啦,这一步我们将创建一个PySide主窗口。

下图是QMainWindow的布局。
![在

image-20221223210213019

在本项目中,我们需要一个“文件”菜单,用来打开文件对话框,和一个“退出”菜单。应用程序启动时,应该要自动加载状态栏。

我们新建一个文件,叫做MainWindow.py

from PySide6.QtCore import Slot
from PySide6.QtGui import QAction,QKeySequence
from PySide6.QtWidgets import QMainWindow

class MainWindow(QMainWindow):
    def __init__(self):
        super(MainWindow, self).__init__()
        pass

让我们的窗体控件继承自QMainWindow。

然后是菜单栏的设置,直接选择获取self.menuBar()即可,通过addMenu(Str)的方式,可以添加选项。

# 设置菜单栏
self.menu=self.menuBar()
# 添加 文件 菜单项
self.file_menu=self.menu.addMenu("文件")

然后我们为菜单栏添加一个退出事件。这个事件可以通过QAction来直接绑定。QAction("Exit",self)表示退出本窗体。

# 退出事件
exit_action=QAction("Exit",self) # 设置名字
exit_action.setShortcut(QKeySequence.Quit) # 设置快捷键
exit_action.triggered.connect(self.close) # 该事件直接与close函数关联

self.file_menu.addAction(exit_action)

再添加状态栏

# 状态栏
self.status=self.statusBar()
self.status.showMessage("Data loaded and plotted")

以及设置窗口尺寸,直接通过self.screen().availableGeometry()方法获取当前可用的窗口大小,我们将主窗体的大小设置为可用窗口大小的(0.8,0.7)。

# 窗口尺寸
geometry=self.screen().availableGeometry()
self.setFixedSize(geometry.width()*0.8,geometry.height()*0.7)

4️⃣ 添加控件

现在,我们需要添加一个表视图,用来显示数据。

我们可以建一个QTableView对象,并将其放置在QHBoxLayout中,并将其作为小部件传递给我们的主窗体。

值得注意的是,QTableView需要一个模型来显示信息。在这种情况下,可以使用QAbstractTableModel实例。

要子类化QAbstractTable,必须重新实现它的抽象方法rowCount()、columnCount()和data()。通过这种方式,可以确保正确地处理数据。此外,重新实现headerData()方法以向视图提供头部信息。

我们再新建一个文件,就叫做TableModel.py好了。

这里我们实现了三个抽象方法。

from PySide6.QtCore import Qt,QAbstractTableModel,QModelIndex
from PySide6.QtGui import QColor

class CustomTableModel(QAbstractTableModel):
    def __init__(self,data=None):
        super(CustomTableModel, self).__init__()
        self.load_data(data)

    def load_data(self,data):
        # 获取UTC日期
        self.input_dates=data[0].values
        # 获取震级
        self.input_magnitudes=data[1].values

        self.column_count=2
        self.row_count=len(self.input_dates)

    def rowCount(self, parent=QModelIndex()):
        # parent需要获取Qt模型索引
        return self.row_count
    def columnCount(self, parent=QModelIndex()):
        # parent需要获取Qt模型索引
        return self.column_count
    def headerData(self,section,orientation,role):
        # 头文件信息
        if role!=Qt.DisplayRole:
            return None
        if orientation==Qt.Horizontal:
            return ("Date","Magnitude")[section]
        else:
            return f"{
      
      section}"

    def data(self,index,role=Qt.DisplayRole):
        # 需要实现的抽象方法
        # index是索引位置,role是当前状态
        
        column=index.column() 
        row=index.row()
        
        if role==Qt.DisplayRole:
            # 如果当前在显示
            if column==0:
                # 表示当前的位置为: row,0 理应返回date数据
                # 返回str格式的数据,除了时区 
                date=self.input_dates[row].toPython()
                return str(date)[:-3]
            elif column==1:
                # 返回震级数据
                magnitude=self.input_magnitudes[row]
                return f"{
      
      magnitude:.2f}"
            
        elif role==Qt.BackgroundRole:
            # 在背后的话就返回一个颜色
            return QColor(Qt.white)
        elif role==Qt.TextAlignmentRole:
            return Qt.AlignRight
        return None

接着就可以再构建我们自己的小控件啦,新建一个文件,叫做TableWidge,将我们的模型导入:

from PySide6.QtWidgets import (QHBoxLayout,QHeaderView,QSizePolicy,QTableView,QWidget)
from Q003_TableModel import CustomTableModel # 我这里是写作Q003,去掉就行,换成上一个文件的名字

class Widget(QWidget):
    def __init__(self,data):
        super(Widget, self).__init__()

        # 获取TableModel
        self.model=CustomTableModel(data) # 基于TableModel的数据读取

        # 创建表视图
        self.table_view=QTableView()
        self.table_view.setModel(self.model) # 设置模型

        # 设置表视图的标头
        self.horizontal_header=self.table_view.horizontalHeader()
        self.vertical_header=self.table_view.verticalHeader()
        self.horizontal_header.setSectionResizeMode(
            QHeaderView.ResizeToContents
        )# 依据内容自动设置尺寸
        self.vertical_header.setSectionResizeMode(
            QHeaderView.ResizeToContents
        )
        self.horizontal_header.setStretchLastSection(True) # 拉伸部件

        # 设置布局
        self.main_layout=QHBoxLayout()
        size=QSizePolicy(QSizePolicy.Preferred,QSizePolicy.Preferred) # 默认sizeHint()为最优尺寸的策略

        # 水平布局
        size.setHorizontalStretch(1) # 设置水平拉伸因子为1
        self.table_view.setSizePolicy(size) # 对组件应用尺寸策略
        self.main_layout.addWidget(self.table_view) # mainlayout是一个水平布局盒子,将我们的组件加进来

        # 将布局设置到QWidget中
        self.setLayout(self.main_layout) # 最后,将我们的水平盒子设为小组件的布局

QSizePolicy 类是布局属性,描述了水平和垂直大小调整策略,部分参数如下:

参数名 作用
Fixed size固定为Qwidget.sizeHint()
Minimum size不能小于sizeHint()的大小
Maximum size不能大于sizeHint()的大小
Preferred 最佳size为sizeHint()
Expanding sizeHint是推荐的size,但尽可能地获取更大的空间
Ignored sizeHint()被忽略,小部件将尽可能获取空间

这里我们用到了void setHorizontalStretch\setVerticalStretch (int stretchFactor)方法,这个方法是用来设置大小策略的水平/垂直拉伸因子的,范围必须在[0,255]。举个栗子,当有两个部件水平相邻时,左边的部件拉伸系数为2,右边的拉深系数为1,那么将确保左边窗口的大小始终是右边的两倍。

好了,现在我们有一个TableView组件啦,将其加入主窗口。在MainWindow.py文件中添加如下代码:

class MainWindow(QMainWindow):
    def __init__(self,widget):
        super(MainWindow, self).__init__()
        self.setCentralWidget(widget)

main.py中添加:

from resource.OtherSupportPyFile.Q003_DataProcess import MainWindow
# 注意写成自己的路径和文件名
from resource.OtherSupportPyFile.Q003_Table import Widget
# 注意写成自己的路径和文件名
import sys 
from PySide6.QtWidgets import QApplication


if __name__ == '__main__':
    options=argparse.ArgumentParser()
    options.add_argument("-f","--file",type=str,required=True)
    args=options.parse_args()
    data=read_data(args.file)

    # 创建应用程序
    app=QApplication()
    widget=Widget(data)
    window=MainWindow(widget)
    window.show()

    sys.exit(app.exec())

好了,最后的结果如下所示:

image-20221223221445259

5️⃣ 添加并绘制图

有了表后,就要添加图了!我们在之前的TableWidge.py上修改,基于Pyside6.QCharts绘制图形。

首先是导入模块:

from PySide6.QtWidgets import (QHBoxLayout,QHeaderView,QSizePolicy,QTableView,QWidget)
from .Q003_TableModel import CustomTableModel
from PySide6.QtCharts import QChart,QChartView,QLineSeries,QDateTimeAxis,QValueAxis
from PySide6.QtGui import QPainter
from PySide6.QtCore import QDateTime,Qt

添加创建图的方法

# 创建QChart对象
self.chat=QChart()
self.chat.setAnimationOptions(QChart.AllAnimations) # 设置移动动画

# 创建表视图
self.chat_view=QChartView(self.chat)
self.chat_view.setRenderHint(QPainter.Antialiasing) # 抗锯齿

将图设置为右边,且大小是表的四倍

# 右布局
size.setHorizontalStretch(4)
self.chat_view.setSizePolicy(size)
self.main_layout.addWidget(self.chat_view)

我们创建一个添加序列的方法,用来为Chart读取数据~

def add_series(self,name):
    # 创建线序列QLineSeries
    self.series=QLineSeries()
    self.series.setName(name)

    # 填充QLineSeries
    for i in range(self.model.row_count):

        t=self.model.index(i,0).data()
        data_fmt="yyyy-MM-dd HH:mm:ss.zzz"
        x=QDateTime().fromString(t,data_fmt).toSecsSinceEpoch() # 转化为时间戳
        y=float(self.model.index(i,1).data())
        if x>0 and y>0:
            self.series.append(x,y)

    self.chat.addSeries(self.series)

    # 设置图样式
    # 设置x坐标
    self.axis_x=QDateTimeAxis()
    self.axis_x.setTickCount(10) # 设置间隔
    # self.axis_x.setFormat("dd.MM (h:mm)") # 设置时间显示
    self.axis_x.setFormat("MM.dd") # 设置时间显示
    self.axis_x.setTitleText("Date")
    self.chat.addAxis(self.axis_x,Qt.AlignBottom) # 在表格中加入坐标,位置为底部
    self.series.attachAxis(self.axis_x) # 自动让QLineSeries贴附

    # 设置y坐标
    self.axis_y=QValueAxis()
    self.axis_y.setTickCount(10)
    self.axis_y.setLabelFormat("%.2f")
    self.axis_y.setTitleText("Magnitude")
    self.chat.addAxis(self.axis_y,Qt.AlignLeft)
    self.series.attachAxis(self.axis_y)

    # 从Chart上获取颜色,并在QTableView上使用
    color_name=self.series.pen().color().name()
    self.model.color=f"{
      
      color_name}"

将其在初始化方法中绑定:

self.add_series("Magnitude")

最终的结果如下!
在这里插入图片描述


完整代码附上:

1️⃣Q003_TableModel.py

from PySide6.QtCore import Qt,QAbstractTableModel,QModelIndex
from PySide6.QtGui import QColor

class CustomTableModel(QAbstractTableModel):
    def __init__(self,data=None):
        super(CustomTableModel, self).__init__()
        self.load_data(data)

    def load_data(self,data):
        # 获取UTC日期
        self.input_dates=data[0].values
        # 获取震级
        self.input_magnitudes=data[1].values

        self.column_count=2
        self.row_count=len(self.input_dates)

    def rowCount(self, parent=QModelIndex()):
        # parent需要获取Qt模型索引
        return self.row_count
    def columnCount(self, parent=QModelIndex()):
        # parent需要获取Qt模型索引
        return self.column_count
    def headerData(self,section,orientation,role):
        # 头文件信息
        if role!=Qt.DisplayRole:
            return None
        if orientation==Qt.Horizontal:
            return ("Date","Magnitude")[section]
        else:
            return f"{
      
      section}"

    def data(self,index,role=Qt.DisplayRole):
        # 需要实现的抽象方法
        # index是索引位置,role是当前状态

        column=index.column()
        row=index.row()

        if role==Qt.DisplayRole:
            # 如果当前在显示
            if column==0:
                # 表示当前的位置为: row,0 理应返回date数据
                # 返回str格式的数据,除了时区
                date=self.input_dates[row].toPython()
                return str(date)[:-3]
            elif column==1:
                # 返回震级数据
                magnitude=self.input_magnitudes[row]
                return f"{
      
      magnitude:.2f}"

        elif role==Qt.BackgroundRole:
            # 在背后的话就返回一个颜色
            return QColor(Qt.white)
        elif role==Qt.TextAlignmentRole:
            return Qt.AlignRight
        return None

2️⃣ Q003_DataProcess.py

from PySide6.QtGui import QAction,QKeySequence
from PySide6.QtWidgets import QMainWindow

class MainWindow(QMainWindow):
    def __init__(self,widget):
        super(MainWindow, self).__init__()
        self.setWindowTitle("Earquakes Information")
        self.setCentralWidget(widget)

        # 设置菜单栏
        self.menu=self.menuBar()
        # 添加 文件 菜单项
        self.file_menu=self.menu.addMenu("文件")

        # 退出事件
        exit_action = QAction("Exit", self)  # 设置名字
        exit_action.setShortcut(QKeySequence.Quit)  # 设置快捷键
        exit_action.triggered.connect(self.close)  # 该事件直接与close函数关联

        self.file_menu.addAction(exit_action)

        # 状态栏
        self.status=self.statusBar()
        self.status.showMessage("Data loaded and plotted")

        # 窗口尺寸
        geometry=self.screen().availableGeometry()
        self.setFixedSize(geometry.width()*0.8,geometry.height()*0.7)

3️⃣ Q003_Table.py

from PySide6.QtWidgets import (QHBoxLayout,QHeaderView,QSizePolicy,QTableView,QWidget)
from .Q003_TableModel import CustomTableModel
from PySide6.QtCharts import QChart,QChartView,QLineSeries,QDateTimeAxis,QValueAxis
from PySide6.QtGui import QPainter
from PySide6.QtCore import QDateTime,Qt


class Widget(QWidget):
    def __init__(self,data):
        super(Widget, self).__init__()

        # 获取TableModel
        self.model=CustomTableModel(data) # 基于TableModel的数据读取

        # 创建表视图
        self.table_view=QTableView()
        self.table_view.setModel(self.model) # 设置模型

        # 设置表视图的标头
        self.horizontal_header=self.table_view.horizontalHeader()
        self.vertical_header=self.table_view.verticalHeader()
        self.horizontal_header.setSectionResizeMode(
            QHeaderView.ResizeToContents
        )# 依据内容自动设置尺寸
        self.vertical_header.setSectionResizeMode(
            QHeaderView.ResizeToContents
        )
        self.horizontal_header.setStretchLastSection(True) # 拉伸部件

        # 创建QChart对象
        self.chat=QChart()
        self.chat.setAnimationOptions(QChart.AllAnimations) # 设置移动动画
        self.add_series("Magnitude")

        # 创建表视图
        self.chat_view=QChartView(self.chat)
        self.chat_view.setRenderHint(QPainter.Antialiasing) # 抗锯齿


        # 设置布局
        self.main_layout=QHBoxLayout()
        size=QSizePolicy(QSizePolicy.Preferred,QSizePolicy.Preferred) # 默认sizeHint()为最优尺寸的策略

        # 左布局
        size.setHorizontalStretch(1) # 设置水平拉伸因子为1
        self.table_view.setSizePolicy(size) # 对组件应用尺寸策略
        self.main_layout.addWidget(self.table_view) # mainlayout是一个水平布局盒子,将我们的组件加进来

        # 右布局
        size.setHorizontalStretch(4)
        self.chat_view.setSizePolicy(size)
        self.main_layout.addWidget(self.chat_view)

        # 将布局设置到QWidget中
        self.setLayout(self.main_layout) # 最后,将我们的水平盒子设为小组件的布局

    def add_series(self,name):
        # 创建线序列QLineSeries
        self.series=QLineSeries()
        self.series.setName(name)

        # 填充QLineSeries
        for i in range(self.model.row_count):

            t=self.model.index(i,0).data()
            data_fmt="yyyy-MM-dd HH:mm:ss.zzz"
            x=QDateTime().fromString(t,data_fmt).toSecsSinceEpoch() # 转化为时间戳
            y=float(self.model.index(i,1).data())
            if x>0 and y>0:
                self.series.append(x,y)

        self.chat.addSeries(self.series)

        # 设置图样式
        # 设置x坐标
        self.axis_x=QDateTimeAxis()
        self.axis_x.setTickCount(10) # 设置间隔
        # self.axis_x.setFormat("dd.MM (h:mm)") # 设置时间显示
        self.axis_x.setFormat("MM.dd") # 设置时间显示
        self.axis_x.setTitleText("Date")
        self.chat.addAxis(self.axis_x,Qt.AlignBottom) # 在表格中加入坐标,位置为底部
        self.series.attachAxis(self.axis_x) # 自动让QLineSeries贴附

        # 设置y坐标
        self.axis_y=QValueAxis()
        self.axis_y.setTickCount(10)
        self.axis_y.setLabelFormat("%.2f")
        self.axis_y.setTitleText("Magnitude")
        self.chat.addAxis(self.axis_y,Qt.AlignLeft)
        self.series.attachAxis(self.axis_y)

        # 从Chart上获取颜色,并在QTableView上使用
        color_name=self.series.pen().color().name()
        self.model.color=f"{
      
      color_name}"

4️⃣ Main.py

import argparse
import pandas as pd
from PySide6.QtCore import QDateTime,QTimeZone
from PySide6.QtWidgets import QApplication
from resource.OtherSupportPyFile.Q003_DataProcess import MainWindow
from resource.OtherSupportPyFile.Q003_Table import Widget
import sys


def transform_date(utc,timezone=None):
    # 日期转换
    utc_fmt="yyyy-MM-ddTHH:mm:ss.zzzZ"
    new_date=QDateTime().fromString(utc,utc_fmt)
    if timezone:
        new_date.setTimeZone(timezone)
    return new_date

def read_data(file):
    # 读取数据
    data=pd.read_csv(file)
    # 处理震级
    data=data.drop(data[data['mag']<0].index)
    magnitudes=data['mag']
    # 时区设置
    timezone = QTimeZone(QTimeZone.systemTimeZone()) # "Asia/Shanghai"
    # 时间转换
    times=data['time'].apply(lambda x:transform_date(x,timezone))
    return times,magnitudes


if __name__ == '__main__':
    options=argparse.ArgumentParser()
    options.add_argument("-f","--file",type=str,required=True)
    args=options.parse_args()
    data=read_data(args.file)
    # 创建应用程序
    app=QApplication()
    widget=Widget(data)
    window=MainWindow(widget)
    window.show()

    sys.exit(app.exec())

猜你喜欢

转载自blog.csdn.net/qq_45957458/article/details/128425208