From 7e3593c89841f7e6645f2dedf5ea25d7bd190978 Mon Sep 17 00:00:00 2001 From: iorebuild Date: Thu, 30 Apr 2026 12:46:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AiAnalysis=20-=2016=E9=80=9A=E9=81=93AI?= =?UTF-8?q?=E6=95=B0=E6=8D=AEUDP=E5=AE=9E=E6=97=B6=E9=87=87=E9=9B=86?= =?UTF-8?q?=E4=B8=8E=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能: - UDP端口监听,实时接收数据 - 解析16通道逗号分隔的ADC数据 - 实时波形绘制(支持滚轮缩放、右键拖拽、双击恢复) - 数据自动保存到 data_端口号/ 目录 - 支持打开历史CSV文件回放查看 - 16通道独立颜色,可切换显示/隐藏 - 深色主题界面 --- .gitignore | 6 + CMakeLists.txt | 22 +++ datamanager.cpp | 127 ++++++++++++++ datamanager.h | 34 ++++ main.cpp | 15 ++ mainwindow.cpp | 332 +++++++++++++++++++++++++++++++++++ mainwindow.h | 67 +++++++ plotwidget.cpp | 456 ++++++++++++++++++++++++++++++++++++++++++++++++ plotwidget.h | 89 ++++++++++ udpreceiver.cpp | 52 ++++++ udpreceiver.h | 32 ++++ 11 files changed, 1232 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 datamanager.cpp create mode 100644 datamanager.h create mode 100644 main.cpp create mode 100644 mainwindow.cpp create mode 100644 mainwindow.h create mode 100644 plotwidget.cpp create mode 100644 plotwidget.h create mode 100644 udpreceiver.cpp create mode 100644 udpreceiver.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f832b17 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +.vscode/ +*.user +*.pro.user +.DS_Store +data_*/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..25c16a7 --- /dev/null +++ b/CMakeLists.txt @@ -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) diff --git a/datamanager.cpp b/datamanager.cpp new file mode 100644 index 0000000..e759e73 --- /dev/null +++ b/datamanager.cpp @@ -0,0 +1,127 @@ +#include "datamanager.h" +#include +#include + +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 &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> DataManager::loadFile(const QString &filePath, QString &error) +{ + QVector> 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; +} diff --git a/datamanager.h b/datamanager.h new file mode 100644 index 0000000..937a07f --- /dev/null +++ b/datamanager.h @@ -0,0 +1,34 @@ +#ifndef DATAMANAGER_H +#define DATAMANAGER_H + +#include +#include +#include +#include +#include + +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 &values); + QString currentFilePath() const; + + static QVector> 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 diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..83481d1 --- /dev/null +++ b/main.cpp @@ -0,0 +1,15 @@ +#include +#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(); +} diff --git a/mainwindow.cpp b/mainwindow.cpp new file mode 100644 index 0000000..62a0f14 --- /dev/null +++ b/mainwindow.cpp @@ -0,0 +1,332 @@ +#include "mainwindow.h" +#include "plotwidget.h" +#include "udpreceiver.h" +#include "datamanager.h" + +#include +#include +#include +#include +#include +#include + +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::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 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 dvalues(16); + for (int i = 0; i < 16; ++i) { + dvalues[i] = static_cast(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); +} diff --git a/mainwindow.h b/mainwindow.h new file mode 100644 index 0000000..6ddeba9 --- /dev/null +++ b/mainwindow.h @@ -0,0 +1,67 @@ +#ifndef MAINWINDOW_H +#define MAINWINDOW_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 m_channelChecks; +}; + +#endif // MAINWINDOW_H diff --git a/plotwidget.cpp b/plotwidget.cpp new file mode 100644 index 0000000..a97626d --- /dev/null +++ b/plotwidget.cpp @@ -0,0 +1,456 @@ +#include "plotwidget.h" +#include +#include +#include +#include +#include + +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 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 &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> &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; +} diff --git a/plotwidget.h b/plotwidget.h new file mode 100644 index 0000000..d358acc --- /dev/null +++ b/plotwidget.h @@ -0,0 +1,89 @@ +#ifndef PLOTWIDGET_H +#define PLOTWIDGET_H + +#include +#include +#include +#include +#include +#include + +class PlotWidget : public QWidget +{ + Q_OBJECT + +public: + explicit PlotWidget(QWidget *parent = nullptr); + + void addDataPoint(const QVector &values); + void setAllData(const QVector> &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 defaultColors(); + + // 数据 + QVector> m_data; // 16个通道 + QVector 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 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 diff --git a/udpreceiver.cpp b/udpreceiver.cpp new file mode 100644 index 0000000..1b48c55 --- /dev/null +++ b/udpreceiver.cpp @@ -0,0 +1,52 @@ +#include "udpreceiver.h" +#include + +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); + } +} diff --git a/udpreceiver.h b/udpreceiver.h new file mode 100644 index 0000000..3324646 --- /dev/null +++ b/udpreceiver.h @@ -0,0 +1,32 @@ +#ifndef UDPRECEIVER_H +#define UDPRECEIVER_H + +#include +#include +#include + +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