diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 1b82e36..c4b9efe 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -29,6 +29,9 @@ add_executable(geopro_desktop WIN32 panels/DescriptionPanel.cpp panels/chart/RawDataChartView.cpp panels/chart/GridDataChartView.cpp + panels/chart/ColorMapService.cpp + panels/chart/ColorBarWidget.cpp + panels/chart/ScatterPlotItem.cpp panels/AnomalyTablePanel.cpp panels/DatasetDetailPage.cpp panels/DatasetDetailPanel.cpp @@ -53,7 +56,9 @@ target_link_libraries(geopro_desktop PRIVATE geopro_controller # Phase 5:导航编排(WorkbenchNavController) ) -# Qwt(二维科学图表;源码存在时由根 CMake 定义 qwt 目标)。本步骤骨架先链好供后续渲染步骤使用。 +# Qwt(二维科学图表;源码存在时由根 CMake 定义 qwt 目标)。 +# cmake/qwt.cmake 已加 QWT_MOC_INCLUDE=1,Qwt Q_OBJECT 类的 MOC 代码直接编译进各 .cpp.obj, +# 标准按需链接可正确解析所有 MOC 符号(无需 /WHOLEARCHIVE)。 if(TARGET qwt) target_link_libraries(geopro_desktop PRIVATE qwt) endif() diff --git a/src/app/panels/chart/ColorBarWidget.cpp b/src/app/panels/chart/ColorBarWidget.cpp new file mode 100644 index 0000000..e2c2350 --- /dev/null +++ b/src/app/panels/chart/ColorBarWidget.cpp @@ -0,0 +1,87 @@ +#include "panels/chart/ColorBarWidget.hpp" +#include +#include + +namespace geopro::app { + +static constexpr int kBarHeight = 18; // 色带高度(px) +static constexpr int kTickHeight = 4; // 刻度短线高度(px) +static constexpr int kFontSize = 9; // 刻度字号 + +ColorBarWidget::ColorBarWidget(QWidget* parent) + : QWidget(parent) { + setFixedHeight(36); +} + +void ColorBarWidget::setColorScale(const core::ColorScale& scale) { + scale_ = scale; + update(); +} + +void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) { + auto stops = scale_.stops(); + if (stops.empty()) return; + + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, false); + + const int W = width(); + const int H = height(); + + // 横向色带区域(顶部) + const int barY = 2; + const int barH = kBarHeight; + + // 边界值 + double minVal = stops.front().first; + double maxVal = stops.back().first; + double range = maxVal - minVal; + if (range <= 0.0) range = 1.0; + + // 绘制每段色格(相邻断点间一格) + for (std::size_t i = 0; i + 1 < stops.size(); ++i) { + double posL = (stops[i].first - minVal) / range; + double posR = (stops[i + 1].first - minVal) / range; + int xL = static_cast(posL * (W - 1)); + int xR = static_cast(posR * (W - 1)); + if (xR <= xL) xR = xL + 1; + const auto& c = stops[i].second; + p.fillRect(xL, barY, xR - xL, barH, QColor(c.r, c.g, c.b, c.a)); + } + // 最后一段(末断点颜色填到边缘) + { + double posL = (stops.back().first - minVal) / range; + int xL = static_cast(posL * (W - 1)); + const auto& c = stops.back().second; + p.fillRect(xL, barY, W - xL, barH, QColor(c.r, c.g, c.b, c.a)); + } + + // 外边框 + p.setPen(QPen(QColor(80, 80, 80), 1)); + p.drawRect(0, barY, W - 1, barH - 1); + + // 刻度区(色带下方) + QFont font = p.font(); + font.setPixelSize(kFontSize); + p.setFont(font); + p.setPen(QColor(200, 200, 200)); + + const int tickY = barY + barH + 1; + + for (const auto& [val, color] : stops) { + double pos = (val - minVal) / range; + int x = static_cast(pos * (W - 1)); + // 刻度短线 + p.drawLine(x, tickY, x, tickY + kTickHeight); + // 数值文字(右对齐到刻度位置,避免越界) + QString label = QString::number(val, 'g', 4); + QFontMetrics fm(font); + int tw = fm.horizontalAdvance(label); + int tx = x - tw / 2; + if (tx < 0) tx = 0; + if (tx + tw > W) tx = W - tw; + p.drawText(tx, H - 1, label); + } +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ColorBarWidget.hpp b/src/app/panels/chart/ColorBarWidget.hpp new file mode 100644 index 0000000..b58d77f --- /dev/null +++ b/src/app/panels/chart/ColorBarWidget.hpp @@ -0,0 +1,24 @@ +#pragma once +#include "model/ColorScale.hpp" +#include + +namespace geopro::app { + +// 独立色阶条 Widget,水平排布:上方彩色色带 + 下方刻度值。 +// 作为 QwtPlot 的兄弟 widget 布局在图表下方,不进入 Qwt 坐标系, +// 因此不随图表缩放/平移移动,也不与轴标注重叠。 +class ColorBarWidget : public QWidget { + Q_OBJECT +public: + explicit ColorBarWidget(QWidget* parent = nullptr); + + void setColorScale(const core::ColorScale& scale); + +protected: + void paintEvent(QPaintEvent* event) override; + +private: + core::ColorScale scale_; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index 5e1eb3e..b592d4a 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -1,4 +1,6 @@ #include "panels/chart/RawDataChartView.hpp" +#include "panels/chart/ColorBarWidget.hpp" +#include "panels/chart/ScatterPlotItem.hpp" #include #include @@ -6,6 +8,11 @@ #include #include +#include +#include +#include +#include + namespace geopro::app { RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { @@ -42,21 +49,70 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { lay->addWidget(toolbar); - // ---- 图表占位区(stretch) ---- - plotArea_ = new QWidget(this); - plotArea_->setObjectName(QStringLiteral("rawPlotArea")); - lay->addWidget(plotArea_, 1); + // ---- QwtPlot(stretch 填满剩余空间)---- + plot_ = new QwtPlot(this); + plot_->setObjectName(QStringLiteral("rawPlotArea")); - // ---- 色阶占位(固定高 36px) ---- - auto* colorScaleBar = new QWidget(this); - colorScaleBar->setObjectName(QStringLiteral("rawColorScaleBar")); - colorScaleBar->setFixedHeight(36); - lay->addWidget(colorScaleBar); + // x 轴顶部,关闭底部 x 轴 + plot_->enableAxis(QwtPlot::xTop, true); + plot_->enableAxis(QwtPlot::xBottom, false); + plot_->enableAxis(QwtPlot::yLeft, true); + + // 深色背景风格 + plot_->setCanvasBackground(QBrush(QColor(30, 30, 30))); + + // 交互:Panner(左键拖动平移)+ Magnifier(滚轮缩放) + auto* panner = new QwtPlotPanner(plot_->canvas()); + panner->setMouseButton(Qt::LeftButton); + + new QwtPlotMagnifier(plot_->canvas()); + + lay->addWidget(plot_, 1); + + // ---- 独立色阶条(固定高 36px,QwtPlot 的兄弟 widget)---- + colorBar_ = new ColorBarWidget(this); + colorBar_->setObjectName(QStringLiteral("rawColorScaleBar")); + lay->addWidget(colorBar_); } -void RawDataChartView::setData(const geopro::controller::DatasetDetailController::ChartData& d) { - // 步骤1:保存数据,图表渲染留给后续步骤。 +QWidget* RawDataChartView::plotArea() const { + return plot_; +} + +void RawDataChartView::setData( + const geopro::controller::DatasetDetailController::ChartData& d) { data_ = d; + + if (d.scatterScale.empty()) return; + + // 重建 ColorMapService(旧的 scatterItem 已被 QwtPlot detach/delete) + delete colorSvc_; + colorSvc_ = new ColorMapService(d.scatterScale); + + // 卸载旧散点项 + if (scatterItem_) { + scatterItem_->detach(); + // QwtPlot 不拥有 item,需要我们释放 + delete scatterItem_; + scatterItem_ = nullptr; + } + + // 新建散点项并挂到 plot + scatterItem_ = new ScatterPlotItem(); + scatterItem_->setData(d.scatter, colorSvc_); + scatterItem_->attach(plot_); + + // 按数据包围盒设置轴范围 + QRectF bbox = scatterItem_->boundingRect(); + if (!bbox.isEmpty()) { + plot_->setAxisScale(QwtPlot::xTop, bbox.left(), bbox.right()); + plot_->setAxisScale(QwtPlot::yLeft, bbox.top(), bbox.bottom()); + } + + plot_->replot(); + + // 更新色阶条 + colorBar_->setColorScale(d.scatterScale); } } // namespace geopro::app diff --git a/src/app/panels/chart/RawDataChartView.hpp b/src/app/panels/chart/RawDataChartView.hpp index 37af190..25bcc58 100644 --- a/src/app/panels/chart/RawDataChartView.hpp +++ b/src/app/panels/chart/RawDataChartView.hpp @@ -1,28 +1,36 @@ #pragma once #include #include "DatasetDetailController.hpp" +#include "panels/chart/ColorMapService.hpp" class QComboBox; +class QwtPlot; namespace geopro::app { -// 原数据图表视图(步骤1骨架):工具条 + 图表占位 + 色阶占位。 -// 图表渲染由后续步骤填入 plotArea()。 +class ColorBarWidget; +class ScatterPlotItem; + +// 原数据图表视图:工具条 + QwtPlot(x 轴顶部、Panner/Magnifier)+ 独立色阶条。 class RawDataChartView : public QWidget { Q_OBJECT public: explicit RawDataChartView(QWidget* parent = nullptr); - // 步骤1:保存数据备后续渲染步骤使用;图表区留空。 void setData(const geopro::controller::DatasetDetailController::ChartData& d); - // 供后续步骤填充 Qwt 画布的占位区域。 - QWidget* plotArea() const { return plotArea_; } + // 供外部访问(已不再是占位,保留兼容接口返回 plot_) + QWidget* plotArea() const; private: geopro::controller::DatasetDetailController::ChartData data_; - QWidget* plotArea_; - QComboBox* chartTypeCombo_; + QwtPlot* plot_; + ColorBarWidget* colorBar_; + QComboBox* chartTypeCombo_; + + // 使用 unique_ptr 管理生命周期;attach 后 QwtPlot 接管绘制,但我们仍持有指针 + ColorMapService* colorSvc_ = nullptr; // heap,由 setData 重建 + ScatterPlotItem* scatterItem_ = nullptr; }; } // namespace geopro::app diff --git a/src/app/panels/chart/ScatterPlotItem.cpp b/src/app/panels/chart/ScatterPlotItem.cpp new file mode 100644 index 0000000..da9c7e3 --- /dev/null +++ b/src/app/panels/chart/ScatterPlotItem.cpp @@ -0,0 +1,67 @@ +#include "panels/chart/ScatterPlotItem.hpp" +#include +#include +#include +#include + +namespace geopro::app { + +ScatterPlotItem::ScatterPlotItem() + : QwtPlotItem() { + setTitle("Scatter"); + setRenderHint(QwtPlotItem::RenderAntialiased, false); +} + +void ScatterPlotItem::setData(const core::ScatterField& field, ColorMapService* svc) { + field_ = field; + colorSvc_ = svc; + + // 计算数据包围盒 + if (field_.x.empty() || field_.y.empty()) { + bounding_ = QRectF(); + return; + } + double xMin = *std::min_element(field_.x.begin(), field_.x.end()); + double xMax = *std::max_element(field_.x.begin(), field_.x.end()); + double yMin = *std::min_element(field_.y.begin(), field_.y.end()); + double yMax = *std::max_element(field_.y.begin(), field_.y.end()); + // 加少量 margin,避免边界点被截 + double xM = (xMax - xMin) * 0.03 + 0.1; + double yM = (yMax - yMin) * 0.03 + 0.1; + bounding_ = QRectF(xMin - xM, yMin - yM, + (xMax - xMin) + 2.0 * xM, + (yMax - yMin) + 2.0 * yM); +} + +QRectF ScatterPlotItem::boundingRect() const { + return bounding_; +} + +void ScatterPlotItem::draw(QPainter* painter, + const QwtScaleMap& xMap, + const QwtScaleMap& yMap, + const QRectF& /*canvasRect*/) const { + if (!colorSvc_) return; + const auto& xs = field_.x; + const auto& ys = field_.y; + const auto& vs = field_.v; + const std::size_t n = xs.size(); + if (n == 0) return; + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, false); + painter->setPen(QPen(Qt::white, kPenWidth)); + + for (std::size_t i = 0; i < n; ++i) { + double px = xMap.transform(xs[i]); + double py = yMap.transform(ys[i]); + double val = (i < vs.size()) ? vs[i] : 0.0; + auto c = colorSvc_->colorAtContinuous(val); + painter->setBrush(QColor(c.r, c.g, c.b, c.a)); + painter->drawRect(QRectF(px - kHalfSide, py - kHalfSide, + kHalfSide * 2.0, kHalfSide * 2.0)); + } + painter->restore(); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterPlotItem.hpp b/src/app/panels/chart/ScatterPlotItem.hpp new file mode 100644 index 0000000..34342a9 --- /dev/null +++ b/src/app/panels/chart/ScatterPlotItem.hpp @@ -0,0 +1,39 @@ +#pragma once +#include "model/Field.hpp" +#include "panels/chart/ColorMapService.hpp" +#include +#include + +class QPainter; +class QwtScaleMap; + +namespace geopro::app { + +// QwtPlotItem:把 ScatterField 数据渲染为彩色方块散点。 +// 每个点用固定像素边长方块绘制(不随缩放变大),白色描边, +// 颜色由 ColorMapService 连续插值决定(与原版 Plotly 一致)。 +class ScatterPlotItem : public QwtPlotItem { +public: + ScatterPlotItem(); + + void setData(const core::ScatterField& field, ColorMapService* svc); + + int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; } + + QRectF boundingRect() const override; + + void draw(QPainter* painter, + const QwtScaleMap& xMap, + const QwtScaleMap& yMap, + const QRectF& canvasRect) const override; + +private: + core::ScatterField field_; + ColorMapService* colorSvc_ = nullptr; // 不拥有,由 RawDataChartView 持有 + QRectF bounding_; + + static constexpr double kHalfSide = 3.5; // 方块半边长(像素) + static constexpr double kPenWidth = 1.0; // 白色描边宽度(像素) +}; + +} // namespace geopro::app