feat(ui): 原数据散点视图(QwtPlot x顶轴+Panner/Magnifier+连续色阶+方块散点+独立色阶条)

- ColorBarWidget:独立 QWidget 色阶条,水平离散色带 + 断点刻度,固定高 36px,
  作为 QwtPlot 兄弟 widget 布局在图表下方,不进 Qwt 坐标系(不随缩放移动)。
- ScatterPlotItem:QwtPlotItem 子类,每点画固定 7px 方块(kHalfSide=3.5px),
  白色描边(1px),颜色由 ColorMapService::colorAtContinuous 连续插值决定。
- RawDataChartView:plotArea 换成 QwtPlot,x 轴顶部(xTop)、关闭 xBottom、yLeft 正常;
  QwtPlotPanner(左键拖动平移)+ QwtPlotMagnifier(滚轮缩放);
  setData 时重建 ColorMapService + ScatterPlotItem,按数据包围盒设轴范围;
  独立色阶条随 setData 更新。
This commit is contained in:
gaozheng 2026-06-11 15:40:43 +08:00
parent c7fec86d3b
commit e405fc1565
7 changed files with 305 additions and 19 deletions

View File

@ -29,6 +29,9 @@ add_executable(geopro_desktop WIN32
panels/DescriptionPanel.cpp panels/DescriptionPanel.cpp
panels/chart/RawDataChartView.cpp panels/chart/RawDataChartView.cpp
panels/chart/GridDataChartView.cpp panels/chart/GridDataChartView.cpp
panels/chart/ColorMapService.cpp
panels/chart/ColorBarWidget.cpp
panels/chart/ScatterPlotItem.cpp
panels/AnomalyTablePanel.cpp panels/AnomalyTablePanel.cpp
panels/DatasetDetailPage.cpp panels/DatasetDetailPage.cpp
panels/DatasetDetailPanel.cpp panels/DatasetDetailPanel.cpp
@ -53,7 +56,9 @@ target_link_libraries(geopro_desktop PRIVATE
geopro_controller # Phase 5WorkbenchNavController geopro_controller # Phase 5WorkbenchNavController
) )
# Qwt CMake qwt 使 # Qwt CMake qwt
# cmake/qwt.cmake QWT_MOC_INCLUDE=1Qwt Q_OBJECT MOC .cpp.obj
# MOC /WHOLEARCHIVE
if(TARGET qwt) if(TARGET qwt)
target_link_libraries(geopro_desktop PRIVATE qwt) target_link_libraries(geopro_desktop PRIVATE qwt)
endif() endif()

View File

@ -0,0 +1,87 @@
#include "panels/chart/ColorBarWidget.hpp"
#include <QPainter>
#include <QPaintEvent>
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<int>(posL * (W - 1));
int xR = static_cast<int>(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<int>(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<int>(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

View File

@ -0,0 +1,24 @@
#pragma once
#include "model/ColorScale.hpp"
#include <QWidget>
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

View File

@ -1,4 +1,6 @@
#include "panels/chart/RawDataChartView.hpp" #include "panels/chart/RawDataChartView.hpp"
#include "panels/chart/ColorBarWidget.hpp"
#include "panels/chart/ScatterPlotItem.hpp"
#include <QComboBox> #include <QComboBox>
#include <QHBoxLayout> #include <QHBoxLayout>
@ -6,6 +8,11 @@
#include <QToolButton> #include <QToolButton>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <qwt_plot.h>
#include <qwt_plot_canvas.h>
#include <qwt_plot_panner.h>
#include <qwt_plot_magnifier.h>
namespace geopro::app { namespace geopro::app {
RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) {
@ -42,21 +49,70 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) {
lay->addWidget(toolbar); lay->addWidget(toolbar);
// ---- 图表占位区stretch ---- // ---- QwtPlotstretch 填满剩余空间)----
plotArea_ = new QWidget(this); plot_ = new QwtPlot(this);
plotArea_->setObjectName(QStringLiteral("rawPlotArea")); plot_->setObjectName(QStringLiteral("rawPlotArea"));
lay->addWidget(plotArea_, 1);
// ---- 色阶占位(固定高 36px ---- // x 轴顶部,关闭底部 x 轴
auto* colorScaleBar = new QWidget(this); plot_->enableAxis(QwtPlot::xTop, true);
colorScaleBar->setObjectName(QStringLiteral("rawColorScaleBar")); plot_->enableAxis(QwtPlot::xBottom, false);
colorScaleBar->setFixedHeight(36); plot_->enableAxis(QwtPlot::yLeft, true);
lay->addWidget(colorScaleBar);
// 深色背景风格
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);
// ---- 独立色阶条(固定高 36pxQwtPlot 的兄弟 widget----
colorBar_ = new ColorBarWidget(this);
colorBar_->setObjectName(QStringLiteral("rawColorScaleBar"));
lay->addWidget(colorBar_);
} }
void RawDataChartView::setData(const geopro::controller::DatasetDetailController::ChartData& d) { QWidget* RawDataChartView::plotArea() const {
// 步骤1保存数据图表渲染留给后续步骤。 return plot_;
}
void RawDataChartView::setData(
const geopro::controller::DatasetDetailController::ChartData& d) {
data_ = 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 } // namespace geopro::app

View File

@ -1,28 +1,36 @@
#pragma once #pragma once
#include <QWidget> #include <QWidget>
#include "DatasetDetailController.hpp" #include "DatasetDetailController.hpp"
#include "panels/chart/ColorMapService.hpp"
class QComboBox; class QComboBox;
class QwtPlot;
namespace geopro::app { namespace geopro::app {
// 原数据图表视图步骤1骨架工具条 + 图表占位 + 色阶占位。 class ColorBarWidget;
// 图表渲染由后续步骤填入 plotArea()。 class ScatterPlotItem;
// 原数据图表视图:工具条 + QwtPlotx 轴顶部、Panner/Magnifier+ 独立色阶条。
class RawDataChartView : public QWidget { class RawDataChartView : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
explicit RawDataChartView(QWidget* parent = nullptr); explicit RawDataChartView(QWidget* parent = nullptr);
// 步骤1保存数据备后续渲染步骤使用图表区留空。
void setData(const geopro::controller::DatasetDetailController::ChartData& d); void setData(const geopro::controller::DatasetDetailController::ChartData& d);
// 供后续步骤填充 Qwt 画布的占位区域。 // 供外部访问(已不再是占位,保留兼容接口返回 plot_
QWidget* plotArea() const { return plotArea_; } QWidget* plotArea() const;
private: private:
geopro::controller::DatasetDetailController::ChartData data_; geopro::controller::DatasetDetailController::ChartData data_;
QWidget* plotArea_; QwtPlot* plot_;
ColorBarWidget* colorBar_;
QComboBox* chartTypeCombo_; QComboBox* chartTypeCombo_;
// 使用 unique_ptr 管理生命周期attach 后 QwtPlot 接管绘制,但我们仍持有指针
ColorMapService* colorSvc_ = nullptr; // heap由 setData 重建
ScatterPlotItem* scatterItem_ = nullptr;
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -0,0 +1,67 @@
#include "panels/chart/ScatterPlotItem.hpp"
#include <QPainter>
#include <qwt_scale_map.h>
#include <algorithm>
#include <limits>
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

View File

@ -0,0 +1,39 @@
#pragma once
#include "model/Field.hpp"
#include "panels/chart/ColorMapService.hpp"
#include <qwt_plot_item.h>
#include <QRectF>
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