feat: AiAnalysis - 16通道AI数据UDP实时采集与分析

功能:
- UDP端口监听,实时接收数据
- 解析16通道逗号分隔的ADC数据
- 实时波形绘制(支持滚轮缩放、右键拖拽、双击恢复)
- 数据自动保存到 data_端口号/ 目录
- 支持打开历史CSV文件回放查看
- 16通道独立颜色,可切换显示/隐藏
- 深色主题界面
This commit is contained in:
iorebuild
2026-04-30 12:46:49 +08:00
commit 7e3593c898
11 changed files with 1232 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
build/
.vscode/
*.user
*.pro.user
.DS_Store
data_*/

22
CMakeLists.txt Normal file
View File

@@ -0,0 +1,22 @@
cmake_minimum_required(VERSION 3.14)
project(AiAnalysis LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
find_package(Qt5 REQUIRED COMPONENTS Core Widgets Network)
add_executable(AiAnalysis
main.cpp
mainwindow.cpp
mainwindow.h
udpreceiver.cpp
udpreceiver.h
plotwidget.cpp
plotwidget.h
datamanager.cpp
datamanager.h
)
target_link_libraries(AiAnalysis Qt5::Core Qt5::Widgets Qt5::Network)

127
datamanager.cpp Normal file
View File

@@ -0,0 +1,127 @@
#include "datamanager.h"
#include <QDir>
#include <QDebug>
DataManager::DataManager(QObject *parent)
: QObject(parent)
, m_recording(false)
, m_headerWritten(false)
{
}
DataManager::~DataManager()
{
stopRecording();
}
bool DataManager::startRecording(const QString &dirPath)
{
stopRecording();
QDir dir(dirPath);
if (!dir.exists()) {
if (!dir.mkpath(".")) {
qWarning() << "无法创建目录:" << dirPath;
return false;
}
}
QString fileName = QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm-ss") + ".csv";
m_filePath = dir.filePath(fileName);
m_file.setFileName(m_filePath);
if (!m_file.open(QIODevice::WriteOnly | QIODevice::Text)) {
qWarning() << "无法创建文件:" << m_filePath;
return false;
}
m_stream.setDevice(&m_file);
m_headerWritten = false;
m_recording = true;
qDebug() << "开始记录数据到:" << m_filePath;
return true;
}
void DataManager::stopRecording()
{
if (m_recording) {
m_stream.flush();
m_file.close();
m_recording = false;
qDebug() << "停止记录, 文件:" << m_filePath;
}
}
bool DataManager::isRecording() const
{
return m_recording;
}
QString DataManager::currentFilePath() const
{
return m_filePath;
}
void DataManager::appendDataPoint(const QVector<int> &values)
{
if (!m_recording) return;
if (!m_headerWritten) {
// 写入表头
QStringList headers;
for (int i = 0; i < 16; ++i) {
headers << QString("ch%1").arg(i);
}
m_stream << headers.join(",") << "\n";
m_headerWritten = true;
}
QStringList parts;
for (int v : values) {
parts << QString::number(v);
}
m_stream << parts.join(",") << "\n";
}
QVector<QVector<double>> DataManager::loadFile(const QString &filePath, QString &error)
{
QVector<QVector<double>> result(16);
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
error = QString("无法打开文件: %1").arg(filePath);
return result;
}
QTextStream stream(&file);
bool firstLine = true;
while (!stream.atEnd()) {
QString line = stream.readLine().trimmed();
if (line.isEmpty()) continue;
// 跳过表头
if (firstLine) {
firstLine = false;
// 检查是否是表头包含非数字字符如ch
if (line.contains("ch", Qt::CaseInsensitive) && !line.at(0).isDigit()) {
continue;
}
}
QStringList parts = line.split(",");
if (parts.size() < 16) continue;
for (int i = 0; i < 16; ++i) {
bool ok;
double val = parts[i].toDouble(&ok);
if (ok) {
result[i].append(val);
}
}
}
file.close();
return result;
}

34
datamanager.h Normal file
View File

@@ -0,0 +1,34 @@
#ifndef DATAMANAGER_H
#define DATAMANAGER_H
#include <QObject>
#include <QFile>
#include <QTextStream>
#include <QVector>
#include <QDateTime>
class DataManager : public QObject
{
Q_OBJECT
public:
explicit DataManager(QObject *parent = nullptr);
~DataManager();
bool startRecording(const QString &dirPath);
void stopRecording();
bool isRecording() const;
void appendDataPoint(const QVector<int> &values);
QString currentFilePath() const;
static QVector<QVector<double>> loadFile(const QString &filePath, QString &error);
private:
QFile m_file;
QTextStream m_stream;
bool m_recording;
QString m_filePath;
bool m_headerWritten;
};
#endif // DATAMANAGER_H

15
main.cpp Normal file
View File

@@ -0,0 +1,15 @@
#include <QApplication>
#include "mainwindow.h"
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
app.setApplicationName("AiAnalysis");
app.setApplicationVersion("1.0");
MainWindow w;
w.resize(1200, 800);
w.show();
return app.exec();
}

332
mainwindow.cpp Normal file
View File

@@ -0,0 +1,332 @@
#include "mainwindow.h"
#include "plotwidget.h"
#include "udpreceiver.h"
#include "datamanager.h"
#include <QApplication>
#include <QStatusBar>
#include <QSplitter>
#include <QScrollArea>
#include <QDebug>
#include <QDir>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
, m_udpReceiver(new UdpReceiver(this))
, m_dataManager(new DataManager(this))
, m_isReceiving(false)
, m_packetCount(0)
{
setWindowTitle("AiAnalysis - AI数据实时分析");
setupUi();
// UDP数据接收
connect(m_udpReceiver, &UdpReceiver::dataReceived, this, &MainWindow::onDataReceived);
connect(m_udpReceiver, &UdpReceiver::errorOccurred, this, [this](const QString &err) {
QMessageBox::warning(this, "错误", err);
});
}
MainWindow::~MainWindow()
{
stopReceiving();
}
void MainWindow::setupUi()
{
QWidget *centralWidget = new QWidget(this);
setCentralWidget(centralWidget);
QVBoxLayout *mainLayout = new QVBoxLayout(centralWidget);
mainLayout->setContentsMargins(8, 8, 8, 8);
mainLayout->setSpacing(6);
// ========== 顶部控制栏 ==========
QHBoxLayout *controlLayout = new QHBoxLayout();
QLabel *portLabel = new QLabel("UDP端口:");
portLabel->setStyleSheet("color: #ccc; font-size: 13px;");
m_portEdit = new QLineEdit();
m_portEdit->setPlaceholderText("输入端口号");
m_portEdit->setMaximumWidth(120);
m_portEdit->setStyleSheet(
"QLineEdit { background: #2a2a3e; color: #fff; border: 1px solid #555; "
"border-radius: 4px; padding: 4px 8px; font-size: 13px; }"
);
m_startStopBtn = new QPushButton("开始监听");
m_startStopBtn->setStyleSheet(
"QPushButton { background: #2ecc71; color: #fff; border: none; "
"border-radius: 4px; padding: 6px 20px; font-size: 13px; font-weight: bold; }"
"QPushButton:hover { background: #27ae60; }"
);
connect(m_startStopBtn, &QPushButton::clicked, this, &MainWindow::onStartStop);
m_openFileBtn = new QPushButton("打开文件");
m_openFileBtn->setStyleSheet(
"QPushButton { background: #3498db; color: #fff; border: none; "
"border-radius: 4px; padding: 6px 16px; font-size: 13px; }"
"QPushButton:hover { background: #2980b9; }"
);
connect(m_openFileBtn, &QPushButton::clicked, this, &MainWindow::onOpenFile);
m_clearBtn = new QPushButton("清除");
m_clearBtn->setStyleSheet(
"QPushButton { background: #e74c3c; color: #fff; border: none; "
"border-radius: 4px; padding: 6px 16px; font-size: 13px; }"
"QPushButton:hover { background: #c0392b; }"
);
connect(m_clearBtn, &QPushButton::clicked, this, &MainWindow::onClear);
QLabel *displayLabel = new QLabel("显示点数:");
displayLabel->setStyleSheet("color: #ccc; font-size: 13px;");
m_displaySpinBox = new QSpinBox();
m_displaySpinBox->setRange(50, 10000);
m_displaySpinBox->setValue(500);
m_displaySpinBox->setSingleStep(100);
m_displaySpinBox->setStyleSheet(
"QSpinBox { background: #2a2a3e; color: #fff; border: 1px solid #555; "
"border-radius: 4px; padding: 4px; font-size: 13px; }"
);
connect(m_displaySpinBox, QOverload<int>::of(&QSpinBox::valueChanged),
this, &MainWindow::onDisplayPointsChanged);
controlLayout->addWidget(portLabel);
controlLayout->addWidget(m_portEdit);
controlLayout->addWidget(m_startStopBtn);
controlLayout->addSpacing(20);
controlLayout->addWidget(m_openFileBtn);
controlLayout->addWidget(m_clearBtn);
controlLayout->addSpacing(20);
controlLayout->addWidget(displayLabel);
controlLayout->addWidget(m_displaySpinBox);
controlLayout->addStretch();
mainLayout->addLayout(controlLayout);
// ========== 中间区域: 绘图 + 通道选择 ==========
QHBoxLayout *centerLayout = new QHBoxLayout();
// 绘图区域
m_plotWidget = new PlotWidget(this);
m_plotWidget->setTitle("AI 数据实时波形");
centerLayout->addWidget(m_plotWidget, 1);
// 右侧通道选择面板
QGroupBox *channelGroup = new QGroupBox("通道选择");
channelGroup->setStyleSheet(
"QGroupBox { color: #ccc; border: 1px solid #444; border-radius: 4px; "
"margin-top: 12px; padding-top: 16px; font-size: 12px; }"
"QGroupBox::title { subcontrol-origin: margin; left: 10px; }"
);
QVBoxLayout *channelLayout = new QVBoxLayout(channelGroup);
channelLayout->setSpacing(2);
for (int i = 0; i < 16; ++i) {
QCheckBox *check = new QCheckBox(QString("ch%1").arg(i));
check->setChecked(true);
check->setStyleSheet(
"QCheckBox { color: #aaa; font-size: 11px; spacing: 4px; }"
"QCheckBox::indicator { width: 12px; height: 12px; }"
"QCheckBox::indicator:checked { background: #3498db; }"
);
connect(check, &QCheckBox::toggled, this, [this, i](bool checked) {
onToggleChannel(i, checked);
});
channelLayout->addWidget(check);
m_channelChecks.append(check);
}
channelLayout->addStretch();
QScrollArea *scrollArea = new QScrollArea();
scrollArea->setWidget(channelGroup);
scrollArea->setWidgetResizable(true);
scrollArea->setMaximumWidth(120);
scrollArea->setStyleSheet("QScrollArea { border: none; background: transparent; }");
centerLayout->addWidget(scrollArea);
mainLayout->addLayout(centerLayout, 1);
// ========== 底部状态栏 ==========
QHBoxLayout *statusLayout = new QHBoxLayout();
m_statusLabel = new QLabel("就绪 - 等待开始监听");
m_statusLabel->setStyleSheet("color: #888; font-size: 12px;");
m_packetLabel = new QLabel("收到: 0 包");
m_packetLabel->setStyleSheet("color: #888; font-size: 12px;");
QLabel *hintLabel = new QLabel("提示: 滚轮缩放 | 右键拖拽平移 | 双击恢复自动范围");
hintLabel->setStyleSheet("color: #555; font-size: 11px;");
statusLayout->addWidget(m_statusLabel);
statusLayout->addStretch();
statusLayout->addWidget(m_packetLabel);
statusLayout->addSpacing(20);
statusLayout->addWidget(hintLabel);
mainLayout->addLayout(statusLayout);
// 全局样式
setStyleSheet("QMainWindow { background-color: #1a1a2e; }");
}
void MainWindow::onStartStop()
{
if (m_isReceiving) {
stopReceiving();
} else {
startReceiving();
}
}
void MainWindow::startReceiving()
{
bool ok;
quint16 port = m_portEdit->text().toUShort(&ok);
if (!ok || port == 0) {
QMessageBox::warning(this, "错误", "请输入有效的端口号 (1-65535)");
return;
}
if (!m_udpReceiver->start(port)) {
return;
}
// 清除旧数据
m_plotWidget->clear();
m_packetCount = 0;
m_packetLabel->setText("收到: 0 包");
// 创建数据保存目录
m_dataDir = QApplication::applicationDirPath() + "/data_" + QString::number(port);
m_dataManager->startRecording(m_dataDir);
m_isReceiving = true;
m_portEdit->setEnabled(false);
m_startStopBtn->setText("停止监听");
m_startStopBtn->setStyleSheet(
"QPushButton { background: #e74c3c; color: #fff; border: none; "
"border-radius: 4px; padding: 6px 20px; font-size: 13px; font-weight: bold; }"
"QPushButton:hover { background: #c0392b; }"
);
m_statusLabel->setText(QString("监听中 - 端口 %1 | 保存至 %2").arg(port).arg(m_dataDir));
m_statusLabel->setStyleSheet("color: #2ecc71; font-size: 12px;");
}
void MainWindow::stopReceiving()
{
m_udpReceiver->stop();
m_dataManager->stopRecording();
m_isReceiving = false;
m_portEdit->setEnabled(true);
m_startStopBtn->setText("开始监听");
m_startStopBtn->setStyleSheet(
"QPushButton { background: #2ecc71; color: #fff; border: none; "
"border-radius: 4px; padding: 6px 20px; font-size: 13px; font-weight: bold; }"
"QPushButton:hover { background: #27ae60; }"
);
m_statusLabel->setText(QString("已停止 - 共收到 %1 包").arg(m_packetCount));
m_statusLabel->setStyleSheet("color: #e74c3c; font-size: 12px;");
}
void MainWindow::onOpenFile()
{
QString filePath = QFileDialog::getOpenFileName(
this, "打开数据文件", m_dataDir, "CSV文件 (*.csv);;所有文件 (*)");
if (filePath.isEmpty()) return;
stopReceiving();
loadAndDisplayFile(filePath);
}
void MainWindow::loadAndDisplayFile(const QString &filePath)
{
QString error;
auto channels = DataManager::loadFile(filePath, error);
if (!error.isEmpty() && channels[0].isEmpty()) {
QMessageBox::warning(this, "错误", error);
return;
}
m_plotWidget->setAllData(channels);
m_plotWidget->setTitle(QString("回放: %1").arg(QFileInfo(filePath).fileName()));
m_statusLabel->setText(QString("回放模式 - %1 | %2 个数据点")
.arg(QFileInfo(filePath).fileName())
.arg(m_plotWidget->totalPoints()));
m_statusLabel->setStyleSheet("color: #3498db; font-size: 12px;");
m_packetLabel->setText(QString("已加载: %1 点").arg(m_plotWidget->totalPoints()));
}
void MainWindow::onClear()
{
m_plotWidget->clear();
m_packetCount = 0;
m_packetLabel->setText("收到: 0 包");
m_statusLabel->setText("已清除");
m_statusLabel->setStyleSheet("color: #888; font-size: 12px;");
}
void MainWindow::onDataReceived(const QByteArray &data)
{
QString raw = QString::fromUtf8(data).trimmed();
if (raw.isEmpty()) return;
// 可能一个UDP包包含多行
QStringList lines = raw.split('\n', Qt::SkipEmptyParts);
for (const QString &line : lines) {
processDataLine(line.trimmed());
}
}
void MainWindow::processDataLine(const QString &line)
{
QStringList parts = line.split(',');
if (parts.size() < 16) return;
QVector<int> values(16);
bool allOk = true;
for (int i = 0; i < 16; ++i) {
bool ok;
values[i] = parts[i].toInt(&ok);
if (!ok) {
allOk = false;
break;
}
}
if (!allOk) return;
// 转换为double给绘图
QVector<double> dvalues(16);
for (int i = 0; i < 16; ++i) {
dvalues[i] = static_cast<double>(values[i]);
}
m_plotWidget->addDataPoint(dvalues);
m_dataManager->appendDataPoint(values);
m_packetCount++;
m_packetLabel->setText(QString("收到: %1 包").arg(m_packetCount));
}
void MainWindow::onDisplayPointsChanged(int count)
{
m_plotWidget->setDisplayPointCount(count);
}
void MainWindow::onToggleChannel(int channel, bool visible)
{
m_plotWidget->setChannelVisible(channel, visible);
}

67
mainwindow.h Normal file
View File

@@ -0,0 +1,67 @@
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
#include <QMainWindow>
#include <QLineEdit>
#include <QPushButton>
#include <QLabel>
#include <QCheckBox>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
#include <QGroupBox>
#include <QSpinBox>
#include <QFileDialog>
#include <QMessageBox>
class PlotWidget;
class UdpReceiver;
class DataManager;
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
~MainWindow();
private slots:
void onStartStop();
void onOpenFile();
void onClear();
void onDataReceived(const QByteArray &data);
void onDisplayPointsChanged(int count);
void onToggleChannel(int channel, bool visible);
private:
void setupUi();
void startReceiving();
void stopReceiving();
void processDataLine(const QString &line);
void loadAndDisplayFile(const QString &filePath);
// 控件
QLineEdit *m_portEdit;
QPushButton *m_startStopBtn;
QPushButton *m_openFileBtn;
QPushButton *m_clearBtn;
QLabel *m_statusLabel;
QLabel *m_packetLabel;
QSpinBox *m_displaySpinBox;
PlotWidget *m_plotWidget;
// 功能组件
UdpReceiver *m_udpReceiver;
DataManager *m_dataManager;
// 状态
bool m_isReceiving;
int m_packetCount;
QString m_dataDir;
// 通道选择复选框
QList<QCheckBox*> m_channelChecks;
};
#endif // MAINWINDOW_H

456
plotwidget.cpp Normal file
View File

@@ -0,0 +1,456 @@
#include "plotwidget.h"
#include <QPainter>
#include <QPainterPath>
#include <QtMath>
#include <QFontMetrics>
#include <algorithm>
PlotWidget::PlotWidget(QWidget *parent)
: QWidget(parent)
, m_totalPoints(0)
, m_displayPoints(500)
, m_yMin(0)
, m_yMax(65535)
, m_autoYRange(true)
, m_autoScroll(true)
, m_dragging(false)
, m_marginLeft(70)
, m_marginRight(160)
, m_marginTop(30)
, m_marginBottom(50)
, m_bgColor("#1a1a2e")
, m_gridColor("#333355")
, m_gridColorMinor("#252545")
, m_textColor("#cccccc")
, m_axisColor("#888888")
, m_dirty(true)
{
m_data.resize(16);
m_channelVisible.fill(true, 16);
m_colors = defaultColors();
setMouseTracking(true);
setMinimumSize(400, 300);
// 定时刷新约30fps
m_refreshTimer = new QTimer(this);
connect(m_refreshTimer, &QTimer::timeout, this, [this]() {
if (m_dirty) {
m_dirty = false;
update();
}
});
m_refreshTimer->start(33);
}
QList<QColor> PlotWidget::defaultColors()
{
return {
QColor("#FF4444"), QColor("#44FF44"), QColor("#4488FF"), QColor("#FFAA00"),
QColor("#FF44FF"), QColor("#44FFFF"), QColor("#FFFF44"), QColor("#FF8888"),
QColor("#88FF88"), QColor("#8888FF"), QColor("#CC6600"), QColor("#FF66CC"),
QColor("#66FFCC"), QColor("#CCFF66"), QColor("#AA66FF"), QColor("#FF6666"),
};
}
void PlotWidget::addDataPoint(const QVector<double> &values)
{
if (values.size() < 16) return;
for (int i = 0; i < 16; ++i) {
m_data[i].append(values[i]);
}
m_totalPoints++;
m_dirty = true;
if (m_autoYRange && m_totalPoints % 50 == 0) {
autoRangeY();
}
}
void PlotWidget::setAllData(const QVector<QVector<double>> &channels)
{
clear();
if (channels.size() >= 16) {
for (int i = 0; i < 16; ++i) {
m_data[i] = channels[i];
}
m_totalPoints = channels.isEmpty() ? 0 : channels[0].size();
}
m_autoScroll = true;
m_autoYRange = true;
autoRangeY();
m_dirty = true;
update();
}
void PlotWidget::clear()
{
for (auto &ch : m_data) {
ch.clear();
}
m_totalPoints = 0;
m_autoScroll = true;
m_autoYRange = true;
m_dirty = true;
update();
}
void PlotWidget::setChannelVisible(int channel, bool visible)
{
if (channel >= 0 && channel < 16) {
m_channelVisible[channel] = visible;
m_dirty = true;
}
}
bool PlotWidget::isChannelVisible(int channel) const
{
return channel >= 0 && channel < 16 ? m_channelVisible[channel] : false;
}
void PlotWidget::setDisplayPointCount(int count)
{
m_displayPoints = qMax(10, count);
m_dirty = true;
}
int PlotWidget::displayPointCount() const
{
return m_displayPoints;
}
void PlotWidget::setYRange(double min, double max)
{
m_yMin = min;
m_yMax = max;
m_autoYRange = false;
m_dirty = true;
}
void PlotWidget::autoRangeY()
{
if (m_totalPoints == 0) {
m_yMin = 0;
m_yMax = 65535;
return;
}
double globalMin = 1e18, globalMax = -1e18;
int startIdx = qMax(0, m_totalPoints - m_displayPoints);
for (int ch = 0; ch < 16; ++ch) {
if (!m_channelVisible[ch]) continue;
const auto &channel = m_data[ch];
for (int i = startIdx; i < channel.size(); ++i) {
double v = channel[i];
if (v < globalMin) globalMin = v;
if (v > globalMax) globalMax = v;
}
}
if (globalMax - globalMin < 1.0) {
globalMin -= 50;
globalMax += 50;
}
double margin = (globalMax - globalMin) * 0.1;
m_yMin = globalMin - margin;
m_yMax = globalMax + margin;
}
void PlotWidget::setTitle(const QString &title)
{
m_title = title;
m_dirty = true;
}
int PlotWidget::totalPoints() const
{
return m_totalPoints;
}
QRectF PlotWidget::plotArea() const
{
return QRectF(m_marginLeft, m_marginTop,
width() - m_marginLeft - m_marginRight,
height() - m_marginTop - m_marginBottom);
}
QPointF PlotWidget::dataToWidget(double x, double y) const
{
QRectF area = plotArea();
double px = area.left() + x * area.width();
double py = area.bottom() - ((y - m_yMin) / (m_yMax - m_yMin)) * area.height();
return QPointF(px, py);
}
// ================ Paint ================
void PlotWidget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, true);
drawBackground(painter);
drawGrid(painter);
drawCurves(painter);
drawAxes(painter);
drawLegend(painter);
// 标题
if (!m_title.isEmpty()) {
painter.setPen(m_textColor);
QFont titleFont = font();
titleFont.setPointSize(12);
titleFont.setBold(true);
painter.setFont(titleFont);
painter.drawText(QRect(0, 2, width(), m_marginTop - 4), Qt::AlignCenter, m_title);
}
// 数据点计数
painter.setPen(QColor("#666666"));
QFont smallFont = font();
smallFont.setPointSize(8);
painter.setFont(smallFont);
QString info = QString("总点数: %1 显示: %2 范围: [%3, %4]")
.arg(m_totalPoints)
.arg(m_displayPoints)
.arg(m_yMin, 0, 'f', 0)
.arg(m_yMax, 0, 'f', 0);
painter.drawText(QRect(m_marginLeft, height() - m_marginBottom + 30,
width() - m_marginLeft - m_marginRight, 20),
Qt::AlignLeft | Qt::AlignVCenter, info);
}
void PlotWidget::drawBackground(QPainter &painter)
{
painter.fillRect(rect(), m_bgColor);
QRectF area = plotArea();
painter.fillRect(area, QColor("#16213e"));
}
void PlotWidget::drawGrid(QPainter &painter)
{
QRectF area = plotArea();
if (area.width() <= 0 || area.height() <= 0) return;
// 计算合适的刻度
double range = m_yMax - m_yMin;
if (range <= 0) return;
double roughStep = range / 8.0;
double exponent = qFloor(log10(roughStep));
double mantissa = roughStep / qPow(10, exponent);
double gridStep;
if (mantissa < 1.5) gridStep = qPow(10, exponent);
else if (mantissa < 3.5) gridStep = 2 * qPow(10, exponent);
else if (mantissa < 7.5) gridStep = 5 * qPow(10, exponent);
else gridStep = 10 * qPow(10, exponent);
if (gridStep <= 0) gridStep = 1;
double firstGrid = qCeil(m_yMin / gridStep) * gridStep;
QPen majorPen(m_gridColor, 1, Qt::SolidLine);
QPen minorPen(m_gridColorMinor, 1, Qt::DotLine);
// Y轴网格
for (double y = firstGrid; y <= m_yMax; y += gridStep) {
painter.setPen(majorPen);
QPointF p = dataToWidget(0, y);
painter.drawLine(QPointF(area.left(), p.y()), QPointF(area.right(), p.y()));
// 标签
painter.setPen(m_textColor);
QFont gridFont = font();
gridFont.setPointSize(8);
painter.setFont(gridFont);
painter.drawText(QRectF(0, p.y() - 10, m_marginLeft - 5, 20),
Qt::AlignRight | Qt::AlignVCenter,
QString::number(y, 'f', 0));
}
// X轴网格时间/采样点)
int startIdx = qMax(0, m_totalPoints - m_displayPoints);
if (m_autoScroll) {
startIdx = qMax(0, m_totalPoints - m_displayPoints);
}
// 画几条竖线
int xStep = m_displayPoints / 5;
if (xStep < 1) xStep = 1;
// 竖网格线(基于显示的采样点)
for (int i = 0; i <= m_displayPoints; i += xStep) {
double t = (double)i / m_displayPoints;
QPointF p = dataToWidget(t, 0);
painter.setPen(minorPen);
painter.drawLine(QPointF(p.x(), area.top()), QPointF(p.x(), area.bottom()));
painter.setPen(m_textColor);
QFont gridFont = font();
gridFont.setPointSize(8);
painter.setFont(gridFont);
int sampleIdx = startIdx + i;
painter.drawText(QRectF(p.x() - 30, area.bottom() + 3, 60, 15),
Qt::AlignCenter, QString::number(sampleIdx));
}
}
void PlotWidget::drawCurves(QPainter &painter)
{
QRectF area = plotArea();
if (area.width() <= 0 || area.height() <= 0) return;
if (m_totalPoints == 0) return;
int startIdx = qMax(0, m_totalPoints - m_displayPoints);
int visibleCount = m_totalPoints - startIdx;
if (visibleCount < 2) return;
for (int ch = 0; ch < 16; ++ch) {
if (!m_channelVisible[ch]) continue;
const auto &channel = m_data[ch];
if (channel.size() <= startIdx) continue;
QPen pen(m_colors[ch], 1.2);
painter.setPen(pen);
QPainterPath path;
bool first = true;
int endIdx = channel.size();
for (int i = startIdx; i < endIdx; ++i) {
double y = channel[i];
double t = (double)(i - startIdx) / (visibleCount - 1);
QPointF pt = dataToWidget(t, y);
if (first) {
path.moveTo(pt);
first = false;
} else {
path.lineTo(pt);
}
}
painter.drawPath(path);
}
}
void PlotWidget::drawAxes(QPainter &painter)
{
QRectF area = plotArea();
// Y轴
painter.setPen(QPen(m_axisColor, 1.5));
painter.drawLine(area.topLeft(), area.bottomLeft());
// X轴
painter.drawLine(area.bottomLeft(), area.bottomRight());
// Y轴标签
painter.setPen(m_textColor);
QFont labelFont = font();
labelFont.setPointSize(9);
painter.setFont(labelFont);
painter.save();
painter.translate(12, area.center().y());
painter.rotate(-90);
painter.drawText(QRectF(-50, -10, 100, 20), Qt::AlignCenter, "ADC Value");
painter.restore();
}
void PlotWidget::drawLegend(QPainter &painter)
{
QRectF area = plotArea();
int legendX = area.right() + 10;
int legendY = area.top() + 5;
int itemHeight = 18;
painter.setPen(m_textColor);
QFont legendFont = font();
legendFont.setPointSize(8);
painter.setFont(legendFont);
for (int ch = 0; ch < 16; ++ch) {
int y = legendY + ch * itemHeight;
// 颜色方块
QColor color = m_channelVisible[ch] ? m_colors[ch] : QColor("#444444");
painter.fillRect(QRect(legendX, y + 2, 12, 12), color);
// 通道名
QString label = QString("ch%1").arg(ch);
painter.setPen(m_channelVisible[ch] ? m_textColor : QColor("#666666"));
painter.drawText(QRect(legendX + 16, y, 50, itemHeight),
Qt::AlignLeft | Qt::AlignVCenter, label);
}
}
// ================ Mouse Events ================
void PlotWidget::wheelEvent(QWheelEvent *event)
{
QRectF area = plotArea();
if (!area.contains(event->position())) return;
double zoomFactor = 1.15;
if (event->angleDelta().y() < 0) {
zoomFactor = 1.0 / zoomFactor;
}
double centerY = m_yMin + (m_yMax - m_yMin) / 2.0;
double halfRange = (m_yMax - m_yMin) / 2.0 * zoomFactor;
m_yMin = centerY - halfRange;
m_yMax = centerY + halfRange;
m_autoYRange = false;
m_dirty = true;
}
void PlotWidget::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::RightButton) {
m_dragging = true;
m_dragStart = event->pos(); // mousePressEvent still uses pos()
m_dragYMinStart = m_yMin;
m_dragYMaxStart = m_yMax;
setCursor(Qt::ClosedHandCursor);
}
}
void PlotWidget::mouseMoveEvent(QMouseEvent *event)
{
if (m_dragging) {
QRectF area = plotArea();
double dy = (event->pos().y() - m_dragStart.y()) / area.height() * (m_yMax - m_yMin);
m_yMin = m_dragYMinStart - dy;
m_yMax = m_dragYMaxStart - dy;
m_autoYRange = false;
m_dirty = true;
}
}
void PlotWidget::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::RightButton) {
m_dragging = false;
setCursor(Qt::ArrowCursor);
}
}
void PlotWidget::mouseDoubleClickEvent(QMouseEvent *)
{
// 双击恢复自动范围
m_autoYRange = true;
m_autoScroll = true;
autoRangeY();
m_dirty = true;
}
void PlotWidget::resizeEvent(QResizeEvent *)
{
m_dirty = true;
}

89
plotwidget.h Normal file
View File

@@ -0,0 +1,89 @@
#ifndef PLOTWIDGET_H
#define PLOTWIDGET_H
#include <QWidget>
#include <QVector>
#include <QColor>
#include <QTimer>
#include <QMouseEvent>
#include <QWheelEvent>
class PlotWidget : public QWidget
{
Q_OBJECT
public:
explicit PlotWidget(QWidget *parent = nullptr);
void addDataPoint(const QVector<double> &values);
void setAllData(const QVector<QVector<double>> &channels);
void clear();
void setChannelVisible(int channel, bool visible);
bool isChannelVisible(int channel) const;
void setDisplayPointCount(int count);
int displayPointCount() const;
void setYRange(double min, double max);
void autoRangeY();
void setTitle(const QString &title);
int totalPoints() const;
protected:
void paintEvent(QPaintEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void mouseDoubleClickEvent(QMouseEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
private:
void drawBackground(QPainter &painter);
void drawGrid(QPainter &painter);
void drawCurves(QPainter &painter);
void drawAxes(QPainter &painter);
void drawLegend(QPainter &painter);
QRectF plotArea() const;
QPointF dataToWidget(double x, double y) const;
static QList<QColor> defaultColors();
// 数据
QVector<QVector<double>> m_data; // 16个通道
QVector<bool> m_channelVisible;
int m_totalPoints;
// 视图参数
int m_displayPoints;
double m_yMin, m_yMax;
bool m_autoYRange;
bool m_autoScroll;
// 鼠标交互
bool m_dragging;
QPoint m_dragStart;
double m_dragYMinStart, m_dragYMaxStart;
// 边距
int m_marginLeft;
int m_marginRight;
int m_marginTop;
int m_marginBottom;
// 颜色
QList<QColor> m_colors;
QColor m_bgColor;
QColor m_gridColor;
QColor m_gridColorMinor;
QColor m_textColor;
QColor m_axisColor;
// 标题
QString m_title;
// 刷新定时器
QTimer *m_refreshTimer;
bool m_dirty;
};
#endif // PLOTWIDGET_H

52
udpreceiver.cpp Normal file
View File

@@ -0,0 +1,52 @@
#include "udpreceiver.h"
#include <QNetworkInterface>
UdpReceiver::UdpReceiver(QObject *parent)
: QObject(parent)
, m_socket(new QUdpSocket(this))
, m_port(0)
{
connect(m_socket, &QUdpSocket::readyRead, this, &UdpReceiver::onReadyRead);
}
UdpReceiver::~UdpReceiver()
{
stop();
}
bool UdpReceiver::start(quint16 port)
{
stop();
m_port = port;
if (!m_socket->bind(QHostAddress::AnyIPv4, port, QUdpSocket::ShareAddress)) {
emit errorOccurred(QString("无法绑定端口 %1: %2").arg(port).arg(m_socket->errorString()));
return false;
}
// 设置足够大的缓冲区
m_socket->setSocketOption(QAbstractSocket::ReceiveBufferSizeSocketOption, 8 * 1024 * 1024);
return true;
}
void UdpReceiver::stop()
{
m_socket->close();
m_port = 0;
}
bool UdpReceiver::isRunning() const
{
return m_socket->state() == QAbstractSocket::BoundState;
}
void UdpReceiver::onReadyRead()
{
while (m_socket->hasPendingDatagrams()) {
QByteArray datagram;
datagram.resize(m_socket->pendingDatagramSize());
m_socket->readDatagram(datagram.data(), datagram.size());
emit dataReceived(datagram);
}
}

32
udpreceiver.h Normal file
View File

@@ -0,0 +1,32 @@
#ifndef UDPRECEIVER_H
#define UDPRECEIVER_H
#include <QObject>
#include <QUdpSocket>
#include <QHostAddress>
class UdpReceiver : public QObject
{
Q_OBJECT
public:
explicit UdpReceiver(QObject *parent = nullptr);
~UdpReceiver();
bool start(quint16 port);
void stop();
bool isRunning() const;
signals:
void dataReceived(const QByteArray &data);
void errorOccurred(const QString &error);
private slots:
void onReadyRead();
private:
QUdpSocket *m_socket;
quint16 m_port;
};
#endif // UDPRECEIVER_H