From 32e0aaec2815fdaea0823f99aeba8e208f4bf221 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 17:00:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20ContourPlotItem=20=E7=BD=91?= =?UTF-8?q?=E6=A0=BC=E5=A1=AB=E5=85=85=E6=A0=85=E6=A0=BC=E7=83=AD=E5=8A=9B?= =?UTF-8?q?=E5=9B=BE=20+=20=E7=9F=A2=E9=87=8F=E7=AD=89=E5=80=BC=E7=BA=BF/?= =?UTF-8?q?=E6=A0=87=E6=B3=A8/=E5=BC=82=E5=B8=B8=E5=8F=A0=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QwtPlotItem(非 Q_OBJECT)。填充用预渲染 ARGB32 QImage(每格细分 K=4, 双线性插值, 离散色带取色 → 平滑填充带边界; 含 NaN 格的像素透明 → 不规则白边), draw 时按数据 bbox 映射目标矩形 blit + SmoothPixmapTransform(拖动/缩放快)。 等值线复用 buildContourBands 的 lines(矢量), 黑 cosmetic 细线; level 由线上代表点采网格值吸附最近色阶级回填(管线恒 0), 沿线方向旋转标注(字号10)。 异常按 markType 画 点(方块)/线(折线)/面(闭合多边形), lineColor + dashed->虚线。 x 轴绑 xBottom, y 轴绑 yLeft。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/CMakeLists.txt | 1 + src/app/panels/chart/ContourPlotItem.cpp | 260 +++++++++++++++++++++++ src/app/panels/chart/ContourPlotItem.hpp | 58 +++++ 3 files changed, 319 insertions(+) create mode 100644 src/app/panels/chart/ContourPlotItem.cpp create mode 100644 src/app/panels/chart/ContourPlotItem.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 0816cd0..a20d844 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -32,6 +32,7 @@ add_executable(geopro_desktop WIN32 panels/chart/ColorMapService.cpp panels/chart/ColorBarWidget.cpp panels/chart/ScatterPlotItem.cpp + panels/chart/ContourPlotItem.cpp panels/chart/LivePanner.cpp panels/AnomalyTablePanel.cpp panels/DatasetDetailPage.cpp diff --git a/src/app/panels/chart/ContourPlotItem.cpp b/src/app/panels/chart/ContourPlotItem.cpp new file mode 100644 index 0000000..cb45281 --- /dev/null +++ b/src/app/panels/chart/ContourPlotItem.cpp @@ -0,0 +1,260 @@ +#include "panels/chart/ContourPlotItem.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "panels/chart/ColorMapService.hpp" + +namespace geopro::app { + +namespace { +constexpr int kFillUpsample = 4; // 填充图像每网格格细分 K(双线性插值平滑带边界) +constexpr int kMaxFillDim = 2400; // 填充图像单边像素上限(防极端网格爆内存) +constexpr int kLabelFontPx = 10; // 等值线标注字号 +constexpr int kLabelMinSegPx = 60; // 太短的等值线不标注(像素长度阈值,draw 期判定) +constexpr double kRad2Deg = 57.29577951308232; // 180/π(避免依赖 M_PI) +} // namespace + +ContourPlotItem::ContourPlotItem() : QwtPlotItem() { + setRenderHint(QwtPlotItem::RenderAntialiased, false); + // 网格数据 x 轴在底部(与 RawDataChartView 的顶部 x 轴不同);y 轴在左。 + setXAxis(QwtPlot::xBottom); + setYAxis(QwtPlot::yLeft); +} + +void ContourPlotItem::setData(const core::Grid& g, ColorMapService* svc, + const std::vector& anoms, bool showLines, + bool showLabels) { + showLines_ = showLines; + showLabels_ = showLabels; + anoms_ = anoms; + + const int nx = g.nx(), ny = g.ny(); + if (nx < 2 || ny < 2 || static_cast(g.x.size()) < nx || + static_cast(g.y.size()) < ny) { + fillImage_ = QImage(); + dataBBox_ = QRectF(); + lines_.clear(); + return; + } + + const double xmin = g.x.front(), xmax = g.x.back(); + const double ymin = g.y.front(), ymax = g.y.back(); + dataBBox_ = QRectF(xmin, ymin, xmax - xmin, ymax - ymin); + + buildFillImage(g, svc); + + // 等值线(矢量):复用 render 管线,仅取 lines(填充走栅格)。 + if (showLines_) { + render::ContourOptions opt; + opt.upsample = 2; + opt.makeLines = true; + auto res = render::buildContourBands(g, svc->scale(), opt); + lines_ = std::move(res.lines); + // buildContourBands 当前未回填 level(恒 0);在此按线上代表点采网格值并吸附到最近色阶级, + // 使标注显示真实等值线值。 + resolveLineLevels(g, svc->scale()); + } else { + lines_.clear(); + } +} + +void ContourPlotItem::resolveLineLevels(const core::Grid& g, const core::ColorScale& cs) { + const auto stops = cs.stopValues(); + if (stops.empty() || lines_.empty()) return; + + const int nx = g.nx(), ny = g.ny(); + const double xmin = g.x.front(), xmax = g.x.back(); + const double ymin = g.y.front(), ymax = g.y.back(); + const double xspan = (xmax - xmin), yspan = (ymax - ymin); + + // 在数据坐标点做双线性采样(NaN 安全)。 + auto sampleAt = [&](const core::Vec2& p) -> double { + if (xspan <= 0 || yspan <= 0) return std::nan(""); + double fi = (p.x - xmin) / xspan * (nx - 1); + double fj = (p.y - ymin) / yspan * (ny - 1); + fi = std::clamp(fi, 0.0, static_cast(nx - 1)); + fj = std::clamp(fj, 0.0, static_cast(ny - 1)); + int i0 = std::min(static_cast(fi), nx - 2); + int j0 = std::min(static_cast(fj), ny - 2); + double ti = fi - i0, tj = fj - j0; + double v00 = g.valueAt(i0, j0), v10 = g.valueAt(i0 + 1, j0); + double v01 = g.valueAt(i0, j0 + 1), v11 = g.valueAt(i0 + 1, j0 + 1); + if (std::isnan(v00) || std::isnan(v10) || std::isnan(v01) || std::isnan(v11)) + return std::nan(""); + return (v00 * (1 - ti) + v10 * ti) * (1 - tj) + (v01 * (1 - ti) + v11 * ti) * tj; + }; + + for (auto& ln : lines_) { + double sampled = std::nan(""); + for (const auto& p : ln.pts) { // 取首个非 NaN 采样点 + sampled = sampleAt(p); + if (!std::isnan(sampled)) break; + } + if (std::isnan(sampled)) { + ln.level = std::nan(""); + continue; + } + // 吸附到最近色阶级(等值线恰落在某级上)。 + double best = stops.front(); + double bestD = std::fabs(sampled - best); + for (double s : stops) { + double d = std::fabs(sampled - s); + if (d < bestD) { bestD = d; best = s; } + } + ln.level = best; + } +} + +void ContourPlotItem::buildFillImage(const core::Grid& g, ColorMapService* svc) { + const int nx = g.nx(), ny = g.ny(); + int W = (nx - 1) * kFillUpsample + 1; + int H = (ny - 1) * kFillUpsample + 1; + // 限幅:极端网格下按比例降采样,保证内存/性能可控。 + if (W > kMaxFillDim) W = kMaxFillDim; + if (H > kMaxFillDim) H = kMaxFillDim; + + QImage img(W, H, QImage::Format_ARGB32); + img.fill(Qt::transparent); + + // 每像素 → 归一化网格坐标 (fi,fj) → 四邻格双线性插值;任一邻格 NaN 则该像素透明。 + for (int py = 0; py < H; ++py) { + // 图像顶行 py=0 对应 y 最大(ymax,地表);底行对应 y 最小(最深)。 + double fj = static_cast(H - 1 - py) / (H - 1) * (ny - 1); + int j0 = std::min(static_cast(fj), ny - 2); + double tj = fj - j0; + auto* scan = reinterpret_cast(img.scanLine(py)); + for (int px = 0; px < W; ++px) { + double fi = static_cast(px) / (W - 1) * (nx - 1); + int i0 = std::min(static_cast(fi), nx - 2); + double ti = fi - i0; + + double v00 = g.valueAt(i0, j0), v10 = g.valueAt(i0 + 1, j0); + double v01 = g.valueAt(i0, j0 + 1), v11 = g.valueAt(i0 + 1, j0 + 1); + if (std::isnan(v00) || std::isnan(v10) || std::isnan(v01) || std::isnan(v11)) + continue; // 含无数据格 → 像素透明(不规则白边) + + double v = (v00 * (1 - ti) + v10 * ti) * (1 - tj) + + (v01 * (1 - ti) + v11 * ti) * tj; + auto c = svc->colorAtDiscrete(v); // 离散色带 → 平滑填充带边界 + scan[px] = qRgba(c.r, c.g, c.b, c.a ? c.a : 255); + } + } + fillImage_ = std::move(img); +} + +QRectF ContourPlotItem::boundingRect() const { + return dataBBox_; +} + +void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const QwtScaleMap& yMap, + const QRectF& /*canvasRect*/) const { + if (dataBBox_.isNull()) return; + + const double xmin = dataBBox_.left(), xmax = dataBBox_.right(); + const double ymin = dataBBox_.top(), ymax = dataBBox_.bottom(); + + // 1) 填充:数据 bbox → 像素矩形(注意 y 翻转:ymax→画布上沿、ymin→下沿),blit + 平滑缩放。 + if (!fillImage_.isNull()) { + const double pxL = xMap.transform(xmin); + const double pxR = xMap.transform(xmax); + const double pyTop = yMap.transform(ymax); + const double pyBot = yMap.transform(ymin); + QRectF target(pxL, pyTop, pxR - pxL, pyBot - pyTop); + painter->save(); + painter->setRenderHint(QPainter::SmoothPixmapTransform, true); + painter->drawImage(target, fillImage_); + painter->restore(); + } + + auto mapPt = [&](const core::Vec2& p) { + return QPointF(xMap.transform(p.x), yMap.transform(p.y)); + }; + + // 2) 等值线:黑色 0 宽(cosmetic)细线。 + if (showLines_ && !lines_.empty()) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + QPen pen(QColor(0, 0, 0)); + pen.setWidthF(0.0); // cosmetic:恒 1px,不随缩放变粗 + painter->setPen(pen); + for (const auto& ln : lines_) { + if (ln.pts.size() < 2) continue; + QPolygonF poly; + poly.reserve(static_cast(ln.pts.size())); + for (const auto& p : ln.pts) poly << mapPt(p); + painter->drawPolyline(poly); + } + painter->restore(); + + // 3) 标注:沿线中段画 level 数值(小字黑,随相邻两点方向旋转)。 + if (showLabels_) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + QFont f = painter->font(); + f.setPixelSize(kLabelFontPx); + painter->setFont(f); + painter->setPen(QColor(0, 0, 0)); + for (const auto& ln : lines_) { + if (ln.pts.size() < 2 || std::isnan(ln.level)) continue; + // 取折线中段两点定位置/朝向。 + const std::size_t mid = ln.pts.size() / 2; + const QPointF a = mapPt(ln.pts[mid - 1]); + const QPointF b = mapPt(ln.pts[mid]); + // 整条线像素长度太短不标注(避免密集杂乱)。 + const QPointF s = mapPt(ln.pts.front()); + const QPointF e = mapPt(ln.pts.back()); + if (std::hypot(e.x() - s.x(), e.y() - s.y()) < kLabelMinSegPx) continue; + double ang = std::atan2(b.y() - a.y(), b.x() - a.x()) * kRad2Deg; + if (ang > 90.0) ang -= 180.0; // 保持文字大体正向(不上下颠倒) + if (ang < -90.0) ang += 180.0; + const QString txt = QString::number(ln.level, 'g', 4); + painter->save(); + painter->translate((a.x() + b.x()) * 0.5, (a.y() + b.y()) * 0.5); + painter->rotate(ang); + painter->drawText(QPointF(-12, -2), txt); + painter->restore(); + } + painter->restore(); + } + } + + // 4) 异常叠加:点=小方块、线=折线、面=闭合多边形;颜色用 lineColor,dashed→虚线。 + if (showAnomalies_ && !anoms_.empty()) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setBrush(Qt::NoBrush); + for (const auto& a : anoms_) { + if (a.localPts.empty()) continue; + QColor col(QString::fromStdString(a.lineColor)); + if (!col.isValid()) col = QColor(0, 0, 0); + QPen pen(col); + pen.setWidthF(a.lineWidth > 0 ? a.lineWidth : 1.0); + pen.setStyle(a.dashed ? Qt::DashLine : Qt::SolidLine); + painter->setPen(pen); + + if (a.markType == core::AnomalyMarkType::Point) { + const QPointF c = mapPt(a.localPts.front()); + painter->drawRect(QRectF(c.x() - 3, c.y() - 3, 6, 6)); + } else { + QPolygonF poly; + poly.reserve(static_cast(a.localPts.size())); + for (const auto& p : a.localPts) poly << mapPt(p); + if (a.markType == core::AnomalyMarkType::Polygon) + painter->drawPolygon(poly); // 闭合 + else + painter->drawPolyline(poly); + } + } + painter->restore(); + } +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ContourPlotItem.hpp b/src/app/panels/chart/ContourPlotItem.hpp new file mode 100644 index 0000000..5052773 --- /dev/null +++ b/src/app/panels/chart/ContourPlotItem.hpp @@ -0,0 +1,58 @@ +#pragma once +#include + +#include +#include +#include + +#include "model/Anomaly.hpp" +#include "model/Field.hpp" +#include "ContourBands.hpp" + +class QPainter; +class QwtScaleMap; + +namespace geopro::app { + +class ColorMapService; + +// 网格等值线图项(QwtPlotItem,非 Q_OBJECT): +// - 填充:预渲染 QImage 热力图(双线性插值 + 离散色带取色),draw 时 blit+平滑缩放 +// (避免 banded 多边形数万顶点导致拖动卡顿);含 NaN 的像素透明 → 不规则白边。 +// - 等值线:buildContourBands 返回的矢量折线(黑细线),随轴变换映射后 drawPolyline。 +// - 标注:沿线方向旋转的 level 数值(小字黑)。 +// - 异常叠加:按 markType 画 点(方块)/线(折线)/面(闭合多边形),dashed→虚线。 +class ContourPlotItem : public QwtPlotItem { +public: + ContourPlotItem(); + + // 构建填充图像 + 缓存等值线/异常。svc 不被拥有(由 GridDataChartView 持有)。 + void setData(const core::Grid& g, ColorMapService* svc, + const std::vector& anoms, bool showLines, bool showLabels); + + void setShowLines(bool on) { showLines_ = on; } + void setShowLabels(bool on) { showLabels_ = on; } + void setShowAnomalies(bool on) { showAnomalies_ = on; } + + 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: + void buildFillImage(const core::Grid& g, ColorMapService* svc); + void resolveLineLevels(const core::Grid& g, const core::ColorScale& cs); + + QImage fillImage_; // 预渲染填充热力图(ARGB32,含透明无数据区) + QRectF dataBBox_; // 数据包围盒(x[xmin,xmax] y[ymin,ymax]) + std::vector lines_; // 矢量等值线(含 level) + std::vector anoms_; // 异常叠加 + + bool showLines_ = true; + bool showLabels_ = true; + bool showAnomalies_ = true; +}; + +} // namespace geopro::app