commit 7e3593c89841f7e6645f2dedf5ea25d7bd190978 Author: iorebuild Date: Thu Apr 30 12:46:49 2026 +0800 feat: AiAnalysis - 16通道AI数据UDP实时采集与分析 功能: - UDP端口监听,实时接收数据 - 解析16通道逗号分隔的ADC数据 - 实时波形绘制(支持滚轮缩放、右键拖拽、双击恢复) - 数据自动保存到 data_端口号/ 目录 - 支持打开历史CSV文件回放查看 - 16通道独立颜色,可切换显示/隐藏 - 深色主题界面 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